function createTodaySessionSync({ fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {}, onOwnedRestore = () => {}, setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval, }) { let current = null; let ownedRevision = 0; let pollTimer = null; let requestTail = Promise.resolve(); let latestSnapshot = null; let publishRequested = 0; let publishSent = 0; const endpoint = 'api/v1/today/session'; const deviceId = () => String(getDeviceId?.() || '').trim(); function serialize(operation) { const result = requestTail.then(operation, operation); requestTail = result.catch(() => null); return result; } function adopt(session) { if (!session || !Number.isInteger(session.revision)) return null; const previousOwned = current?.device_id === deviceId() && (current?.running || Number.isFinite(current?.break_deadline_at)); current = session; if (session.device_id === deviceId()) { ownedRevision = session.revision; if (Number.isFinite(session.break_deadline_at) && timer?.restoreBreak?.(session.identity, session.break_deadline_at)) onOwnedRestore(session); onRemote(null); } else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) { if (previousOwned) { timer?.pause?.(); onTransferred(session); } onRemote(session); } else { onRemote(null); } return session; } async function refreshNow() { try { onStatus('syncing'); const session = await fetchJson(endpoint); onStatus('online'); return adopt(session); } catch (error) { onStatus('offline', error); return null; } } const refresh = () => serialize(refreshNow); function claim() { return serialize(async () => { if ((!current?.running && !Number.isFinite(current?.break_deadline_at)) || !current.identity || current.device_id === deviceId() || !deviceId()) return null; try { onStatus('syncing'); const body = { base_revision:current.revision, device_id:deviceId(), identity:current.identity, elapsed_ms:current.elapsed_ms, entries:current.entries, running:true, }; if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null; const session = await fetchJson(endpoint, { method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body), }); adopt(session); timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries); return session; } catch (error) { if (error?.status === 409 || error?.code === 'session_changed') { onStatus('conflict', error); await refreshNow(); } else { onStatus('offline', error); } return null; } }); } async function publishNow(snapshot) { if (!snapshot?.identity || !deviceId()) return null; try { onStatus('syncing'); const body = { base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity, elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)), entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined, running:Boolean(snapshot.running), }; if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) { body.break_deadline_at = Number.isFinite(snapshot.break_deadline_at) ? Math.floor(snapshot.break_deadline_at) : null; } const session = await fetchJson(endpoint, { method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body), }); adopt(session); return session; } catch (error) { if (error?.status === 409 || error?.code === 'session_changed') { onStatus('conflict', error); await refreshNow(); } else { onStatus('offline', error); } return null; } } function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) { snapshot = snapshot?.identity ? snapshot : latestSnapshot; if (!snapshot?.identity || !deviceId()) return Promise.resolve(null); latestSnapshot = snapshot; publishRequested += 1; return serialize(async () => { if (publishSent === publishRequested) return current; if (current?.device_id && current.device_id !== deviceId()) { publishSent = publishRequested; return current; } const version = publishRequested; const result = await publishNow(latestSnapshot); publishSent = current?.device_id && current.device_id !== deviceId() ? publishRequested : version; return result; }); } const pulse = () => current?.device_id === deviceId() && current?.running ? publish() : refresh(); return { refresh, claim, publish, session:() => current, start(intervalMs = 15000) { if (pollTimer !== null) return false; pulse(); pollTimer = setInterval?.(pulse, intervalMs); pollTimer?.unref?.(); return true; }, stop() { if (pollTimer === null) return false; clearInterval?.(pollTimer); pollTimer = null; return true; }, }; } function todaySessionHandoffSummary(session, item) { if (Number.isFinite(session?.break_deadline_at)) { const end = new Date(session.break_deadline_at).toLocaleTimeString([], { hour:'numeric', minute:'2-digit', }); return `On break until ${end} · ready to resume here`; } const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000)); if (Array.isArray(session?.entries) && session.entries.length > 1) { const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0); return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`; } return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`; } function attachTodaySessionHandoff({ fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer, }) { const deviceKey = 'stackchain.today-session-device.v1'; const getDeviceId = () => { let value = storage.getItem(deviceKey); if (!value) { value = globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2); storage.setItem(deviceKey, value); } return value; }; let offered = null; const sessionStatus = qs('#today-session-sync-status'); const showSessionStatus = message => { sessionStatus.textContent = message; sessionStatus.hidden = !message; }; const sync = createTodaySessionSync({ fetchJson, getDeviceId, timer, onOwnedRestore:renderTimer, onStatus:state => showSessionStatus( state === 'syncing' ? 'Session syncing…' : (state === 'offline' ? 'Session offline · will retry.' : '') ), onRemote:session => { offered = session; const handoff = qs('#today-session-handoff'); handoff.hidden = !session; if (!session) return; const item = items().find(entry => identity(entry) === session.identity); qs('#today-session-handoff-summary').textContent = todaySessionHandoffSummary(session, item); qs('#continue-today-session').textContent = Number.isFinite(session.break_deadline_at) ? 'Resume Today here' : 'Continue here'; }, onTransferred:() => { showSessionStatus('Session continued on another device.'); announce('Today continued on another device. Timer paused here.'); renderTimer(); }, }); qs('#continue-today-session').addEventListener('click', async () => { const target = offered; const claimed = await sync.claim(); if (!claimed || !target) return; selectToday(); const item = items().find(entry => identity(entry) === claimed.identity); if (item) startItem(item); else announce('Today session moved here; refresh work to open its item.'); renderTimer(); }); sync.start(5000); return sync; } if (typeof module !== 'undefined' && module.exports) module.exports = { createTodaySessionSync, attachTodaySessionHandoff, todaySessionHandoffSummary, };