diff --git a/frontend/today-session-sync.js b/frontend/today-session-sync.js index e0ebdd3..35da98d 100644 --- a/frontend/today-session-sync.js +++ b/frontend/today-session-sync.js @@ -1,5 +1,6 @@ function createTodaySessionSync({ fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {}, + onOwnedRestore = () => {}, setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval, }) { let current = null; @@ -25,6 +26,8 @@ function createTodaySessionSync({ 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) { @@ -186,6 +189,7 @@ function attachTodaySessionHandoff({ }; const sync = createTodaySessionSync({ fetchJson, getDeviceId, timer, + onOwnedRestore:renderTimer, onStatus:state => showSessionStatus( state === 'syncing' ? 'Session syncing…' : (state === 'offline' ? 'Session offline · will retry.' : '') diff --git a/frontend/today-timer.js b/frontend/today-timer.js index 958900d..03eb206 100644 --- a/frontend/today-timer.js +++ b/frontend/today-timer.js @@ -159,6 +159,15 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange state.timed_break = { identity, deadline_at:now() + duration * 60000 }; return write(state) ? validBreak(state) : false; }, + restoreBreak(identity, deadlineAt) { + const deadline = Number(deadlineAt); + if (typeof identity !== 'string' || !identity || !Number.isFinite(deadline) || deadline <= now()) return false; + const state = read(); + const entry = state.entries[identity]; + if (state.active_identity !== identity || !entry || entry.running || validBreak(state)) return false; + state.timed_break = { identity, deadline_at:Math.floor(deadline) }; + return write(state); + }, breakSnapshot() { return validBreak(read()); }, diff --git a/tests/test_today_session_sync.py b/tests/test_today_session_sync.py index faa8c66..b6f6882 100644 --- a/tests/test_today_session_sync.py +++ b/tests/test_today_session_sync.py @@ -4,6 +4,7 @@ from pathlib import Path SOURCE = Path(__file__).parents[1] / "frontend" / "today-session-sync.js" +TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js" def run_node(script: str) -> dict: @@ -92,6 +93,43 @@ const sync=createTodaySessionSync({ assert result["claimed"]["device_id"] == "desktop-b" +def test_owner_refresh_recovers_a_server_confirmed_break_missing_from_device_state(): + result = run_node( + f""" +const createTimer = require({json.dumps(str(TIMER))}); +const values = new Map(); +const storage = {{ + getItem:key=>values.get(key)||null, + setItem:(key,value)=>values.set(key,value), +}}; +const timer = createTimer({{storage,getLogin:()=> 'timmy',now:()=>100000}}); +timer.adopt('issue:r:42:', 90000, false); +let rendered = 0; +const remote = {{ + revision:3,device_id:'phone-a',identity:'issue:r:42:',elapsed_ms:90000, + running:false,break_deadline_at:400000,updated_at:10, +}}; +const sync = createTodaySessionSync({{ + getDeviceId:()=> 'phone-a',fetchJson:async()=>remote,timer, + onOwnedRestore:()=>{{rendered += 1;}}, +}}); +(async()=>{{ + await sync.refresh(); + process.stdout.write(JSON.stringify({{breakSnapshot:timer.breakSnapshot(),rendered}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + ) + + assert result == { + "breakSnapshot": { + "identity": "issue:r:42:", + "deadline_at": 400_000, + "expired": False, + }, + "rendered": 1, + } + + def test_break_handoff_summary_is_actionable_and_privacy_safe(): result = run_node( r"""