From ed153920976de8fea55c77ad83bf0eeb320d4bdf Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 17 Aug 2026 23:57:29 +0000 Subject: [PATCH] feat: show timed Today breaks on lock screen (Closes #1050) --- frontend/service-worker.js | 30 +++++++++++++----- frontend/today-lock-screen.js | 14 +++++++-- tests/test_service_worker.py | 55 +++++++++++++++++++++++++++++++++ tests/test_today_lock_screen.py | 25 ++++++++++++++- 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 468e910..62ab32e 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -421,7 +421,7 @@ self.addEventListener('sync', event => { if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify()); }); -async function updateTodayLockScreen(active, running, rawActionToken = '') { +async function updateTodayLockScreen(active, running, rawActionToken = '', rawBreakDeadline = 0) { const tag = 'stackchain-today-session'; if (!active) { const notifications = await self.registration.getNotifications({ tag }); @@ -429,12 +429,24 @@ async function updateTodayLockScreen(active, running, rawActionToken = '') { return; } const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : ''; - await self.registration.showNotification(running ? 'Today session running' : 'Today session paused', { - body: running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.', + const now = Date.now(); + const breakDeadline = Number(rawBreakDeadline); + const onBreak = !running && actionToken && Number.isSafeInteger(breakDeadline) && + breakDeadline > now && breakDeadline <= now + 120 * 60 * 1000; + const title = onBreak ? 'On a Today break' : + running ? 'Today session running' : 'Today session paused'; + const body = onBreak ? 'Return at ' + new Date(breakDeadline).toLocaleTimeString([], { + hour:'numeric', minute:'2-digit', + }) : running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.'; + await self.registration.showNotification(title, { + body, tag, renotify:false, silent:true, - actions: [ + actions: onBreak ? [ + { action:'resume-today', title:'Resume now' }, + { action:'open-today', title:'Open Today' }, + ] : [ { action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' }, ...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []), ], @@ -464,7 +476,8 @@ self.addEventListener('message', event => { event.waitUntil(updateTodayLockScreen( event.data.active === true, event.data.running === true, - String(event.data.actionToken || '') + String(event.data.actionToken || ''), + Number(event.data.breakDeadlineAt || 0) )); } }); @@ -561,19 +574,20 @@ async function openCanonicalIssueUrl(rawUrl) { async function applyTodayTimerAction(action, actionToken = '') { if (!['pause', 'resume', 'complete'].includes(action)) return; - if (action === 'complete' && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return; + if (actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return; + if (action === 'complete' && !actionToken) return; const route = '#/my-work/today'; const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true }); const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE)); if (client) { client.postMessage?.({ type:'stackchain-today-timer-action', action, - ...(action === 'complete' ? { actionToken } : {}), + ...(actionToken ? { actionToken } : {}), }); return client.focus?.(); } const query = '?today_timer_action=' + action + - (action === 'complete' ? '&today_action_token=' + encodeURIComponent(actionToken) : ''); + (actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : ''); const target = new URL(BASE + query + route, self.location.origin).href; return self.clients.openWindow(target); } diff --git a/frontend/today-lock-screen.js b/frontend/today-lock-screen.js index a1314e5..fa53577 100644 --- a/frontend/today-lock-screen.js +++ b/frontend/today-lock-screen.js @@ -21,6 +21,8 @@ function createTodayLockScreen({ onAction = () => {}, }) { let activeIdentity = ''; + let activeActionIdentity = ''; + let activeBreakDeadline = 0; const preferenceKey = () => { const login = String(getLogin?.() || '').trim().toLowerCase(); return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : ''; @@ -82,13 +84,14 @@ function createTodayLockScreen({ }; const consumeAction = async (action, token = '') => { if (!enabled() || !['pause', 'resume', 'complete'].includes(action)) return false; - if (action !== 'complete') { + const requiresToken = action === 'complete' || (action === 'resume' && activeBreakDeadline > 0); + if (!requiresToken) { onAction(action, null); return true; } const pending = readAction(); if (!pending || !token || !activeIdentity || pending.token !== token || - await fingerprint(token, activeIdentity) !== pending.fingerprint) return false; + await fingerprint(token, activeActionIdentity) !== pending.fingerprint) return false; if (!clearAction()) return false; onAction(action, activeIdentity); return true; @@ -128,12 +131,17 @@ function createTodayLockScreen({ if (!enabled() || NotificationRef?.permission !== 'granted') return false; const visible = Boolean(active && snapshot?.identity); activeIdentity = visible ? snapshot.identity : ''; - const pending = visible ? await actionFor(activeIdentity) : null; + const rawBreakDeadline = Number(snapshot?.break_deadline_at); + activeBreakDeadline = visible && Number.isSafeInteger(rawBreakDeadline) && rawBreakDeadline > 0 ? + rawBreakDeadline : 0; + activeActionIdentity = activeIdentity + (activeBreakDeadline ? '\0break:' + activeBreakDeadline : ''); + const pending = visible ? await actionFor(activeActionIdentity) : null; if (!visible) clearAction(); await post({ type:'stackchain-today-lock-screen', active:visible, running:visible && Boolean(snapshot.running), + ...(activeBreakDeadline ? { breakDeadlineAt:activeBreakDeadline } : {}), ...(pending?.token ? { actionToken:pending.token } : {}), }); if (visible && !pending) setStatus('Finish current is unavailable because its one-time action could not be saved.'); diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index cfe0f7c..fd1fa38 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -531,6 +531,40 @@ def test_active_today_message_replaces_one_privacy_safe_lock_screen_notification assert "secret" not in json.dumps(result["notifications"]) +def test_live_today_break_shows_return_time_and_safe_resume_controls(): + result = run_worker_scenario( + """ + const deadline = Date.now() + 5 * 60 * 1000; + await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:false,breakDeadlineAt:deadline,identity:'secret/repo#42',actionToken:'opaque-token-1234567890'}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + notification = result["notifications"][0] + assert notification["title"] == "On a Today break" + assert notification["options"]["body"].startswith("Return at ") + assert notification["options"]["actions"] == [ + {"action": "resume-today", "title": "Resume now"}, + {"action": "open-today", "title": "Open Today"}, + ] + assert notification["options"]["data"]["actionToken"] == "opaque-token-1234567890" + assert "secret" not in json.dumps(notification) + + +def test_invalid_or_expired_break_deadline_falls_back_to_paused_controls(): + result = run_worker_scenario( + """ + await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:false,breakDeadlineAt:Date.now()-1,actionToken:'opaque-token-1234567890'}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["notifications"][0]["title"] == "Today session paused" + assert result["notifications"][0]["options"]["actions"][0] == { + "action": "resume-today", "title": "Resume" + } + + def test_inactive_today_message_closes_the_lock_screen_notification(): result = run_worker_scenario( """ @@ -610,6 +644,27 @@ def test_finish_today_lock_screen_action_forwards_only_its_opaque_token(): }] +def test_break_resume_action_forwards_opaque_token_to_reject_stale_breaks(): + result = run_worker_scenario( + """ + state.clientMessages=[]; + state.clientList=[{ + url:'https://forge.example/dashboard/#/my-work/today', + postMessage:message=>state.clientMessages.push(message), + focus:async()=>state.focused.push('today'), + }]; + await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session',null,'opaque-token-1234567890'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["clientMessages"] == [{ + "type": "stackchain-today-timer-action", + "action": "resume", + "actionToken": "opaque-token-1234567890", + }] + + def test_background_mutation_abort_also_cancels_stalled_csrf_lookup(): result = run_worker_scenario( """ diff --git a/tests/test_today_lock_screen.py b/tests/test_today_lock_screen.py index db4e7bb..7d9e088 100644 --- a/tests/test_today_lock_screen.py +++ b/tests/test_today_lock_screen.py @@ -28,7 +28,7 @@ const historyRef = {replaceState:(_a,_b,url)=>{locationRef.href=new URL(url, loc let tokenCounter = 0; const tokens = ['opaque-token-1234567890', 'new-opaque-token-0987654321']; (async()=>{ - const lockScreen = createTodayLockScreen({storage,getLogin:()=> 'Timmy',serviceWorker,NotificationRef,control,status,locationRef,historyRef,randomToken:()=> tokens[tokenCounter++],fingerprint:async (_token, identity)=>identity.startsWith('issue:') ? 'opaque-issue-fingerprint' : 'opaque-pull-fingerprint',onAction:(action, identity)=>actions.push([action, identity])}); + const lockScreen = createTodayLockScreen({storage,getLogin:()=> 'Timmy',serviceWorker,NotificationRef,control,status,locationRef,historyRef,randomToken:()=> tokens[tokenCounter++],fingerprint:async (_token, identity)=>identity.includes('break:1000000') ? 'opaque-break-fingerprint' : identity.startsWith('issue:') ? 'opaque-issue-fingerprint' : 'opaque-pull-fingerprint',onAction:(action, identity)=>actions.push([action, identity])}); %SCENARIO% })().catch(error=>{console.error(error);process.exit(1)}); """.replace("%SCENARIO%", scenario) @@ -110,6 +110,29 @@ def test_finish_action_is_bound_to_the_exact_active_identity_and_consumed_once() assert all("private/repo" not in json.dumps(message) for message in result["messages"]) +def test_break_sync_carries_deadline_and_resume_is_bound_to_that_exact_break(): + result = run_scenario(r""" + await lockScreen.enable(); + await lockScreen.sync({identity:'issue:private/repo:42:',running:false,break_deadline_at:1000000}, true); + const resumed = await lockScreen.consumeAction('resume', 'opaque-token-1234567890'); + await lockScreen.sync({identity:'issue:private/repo:42:',running:false,break_deadline_at:2000000}, true); + const stale = await lockScreen.consumeAction('resume', 'opaque-token-1234567890'); + process.stdout.write(JSON.stringify({resumed,stale,actions,messages,stored:[...values.entries()]})); +""") + + assert result["resumed"] is True + assert result["stale"] is False + assert result["actions"] == [["resume", "issue:private/repo:42:"]] + assert result["messages"][0] == { + "type": "stackchain-today-lock-screen", + "active": True, + "running": False, + "breakDeadlineAt": 1_000_000, + "actionToken": "opaque-token-1234567890", + } + assert "private/repo" not in json.dumps(result["messages"]) + + def test_cold_launch_finish_action_is_removed_from_history_before_completion(): result = run_scenario(r""" await lockScreen.enable(); -- 2.43.0