const token = window.location.pathname.split('/').filter(Boolean).pop(); const weekdayBoard = document.getElementById('weekdayBoard'); const weekendWrap = document.getElementById('weekendWrap'); async function apiGet(path) { const res = await fetch(path, { credentials: 'omit' }); if (!res.ok) throw new Error('request failed'); return res.json(); } async function apiPatch(path, body) { const res = await fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error('request failed'); return res.json(); } async function apiPost(path) { const res = await fetch(path, { method: 'POST' }); if (!res.ok) throw new Error('request failed'); return res.json(); } const handlers = { onToggleDone: (taskId, done) => apiPatch(`/api/kiosk/${token}/tasks/${taskId}`, { done }).catch(() => {}), onStartTimer: (taskId) => { // Played synchronously inside this real click handler — this is what // unlocks audio for the rest of the page session on iOS. playChime('started'); apiPost(`/api/kiosk/${token}/tasks/${taskId}/start`).catch(() => {}); }, }; let activeTimers = []; // taskId -> { startedAt, fired: Set }, in-memory only, reset // on page load — audio is "while actively watching," not a durable log. const milestoneState = new Map(); const MILESTONES = [ { key: 'halfway', at: 0.5 }, { key: 'almostDone', at: 0.85 }, { key: 'done', at: 1.0 }, ]; function formatRemaining(seconds) { const s = Math.max(0, Math.round(seconds)); const m = Math.floor(s / 60); const rem = s % 60; return `${m}:${String(rem).padStart(2, '0')}`; } function tickTimers() { const now = Date.now(); activeTimers.forEach((timer) => { const totalSec = timer.durationMinutes * 60; const elapsedSec = (now - Date.parse(timer.startedAt)) / 1000; const fraction = Math.min(1, Math.max(0, elapsedSec / totalSec)); const remainingSec = totalSec - elapsedSec; timer.fillEl.style.width = `${fraction * 100}%`; timer.fillEl.classList.toggle('milestone-almostDone', fraction >= 0.85 && fraction < 1); timer.fillEl.classList.toggle('milestone-done', fraction >= 1); timer.labelEl.textContent = fraction >= 1 ? "Time's up!" : formatRemaining(remainingSec); let state = milestoneState.get(timer.taskId); if (!state || state.startedAt !== timer.startedAt) { // First time seeing this timer (page load, or a fresh restart with a // new anchor) — backfill already-passed milestones without sound, so // a reload mid-countdown doesn't replay history. state = { startedAt: timer.startedAt, fired: new Set() }; MILESTONES.forEach((m) => { if (fraction >= m.at) state.fired.add(m.key); }); milestoneState.set(timer.taskId, state); } MILESTONES.forEach((m) => { if (fraction >= m.at && !state.fired.has(m.key)) { state.fired.add(m.key); playChime(m.key); } }); }); } function todayName() { return new Date().toLocaleDateString('en-US', { weekday: 'long' }); } function draw(calendar) { document.getElementById('childName').textContent = calendar.childName || ''; document.getElementById('calTitle').textContent = calendar.title || ''; const result = renderCalendar({ calendar, editable: false, weekdayBoard, weekendWrap, handlers, todayName: todayName(), }); activeTimers = result.activeTimers; tickTimers(); weekendWrap.classList.toggle('hidden', !calendar.showWeekend); } async function poll() { try { const data = await apiGet(`/api/kiosk/${token}/calendar`); if (!data.calendar) { document.getElementById('emptyState').style.display = 'block'; document.getElementById('childName').textContent = data.child ? data.child.name : ''; return; } document.getElementById('emptyState').style.display = 'none'; draw(data.calendar); } catch { // transient network error — try again next tick } } poll(); setInterval(poll, 4000); setInterval(tickTimers, 250);