diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 1c6cc14..aa0b0e2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1807,10 +1807,16 @@ }, }); let todaySessionSync = null; + let todayLockScreen = null; const timer = createTodayTimer({ storage: localStorage, getLogin: () => confirmedOwnerLogin, - onChange: snapshot => todaySessionSync?.publish(snapshot), + onChange: snapshot => { + todaySessionSync?.publish(snapshot); + queueMicrotask(() => { + if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed()); + }); + }, }); const timerView = createTodayTimerView({ timer, @@ -1939,6 +1945,28 @@ todayRecapView.finish(selectedWorkFilter); }, }); + todayLockScreen = createTodayLockScreen({ + storage:localStorage, + getLogin:() => confirmedOwnerLogin, + serviceWorker:navigator.serviceWorker, + NotificationRef:window.Notification, + control:qs('#today-lock-screen'), + status:qs('#today-lock-screen-status'), + locationRef:window.location, + historyRef:window.history, + onAction:action => { + const state = timer.snapshot(); + if (!state.identity || (action === 'pause' && !state.running) || (action === 'resume' && state.running)) return; + const changed = action === 'pause' ? timer.pause() : timer.resume(); + if (changed === false) return; + selectTodayWork(); + const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity); + if (item) workSession.reopen(item); + timerView.render(); + }, + }); + todayLockScreen.consumeLaunchAction(); + todayLockScreen.sync(timer.snapshot(), workSession.checkpointed()); function selectTodayWork() { qs('[data-work-filter="today"]').click(); } diff --git a/frontend/index.html b/frontend/index.html index c49efc8..033a81c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -182,6 +182,8 @@ + + @@ -1624,6 +1626,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 9a89135..eb4c030 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v118'; +const CACHE = 'stackchain-dashboard-shell-v119'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -51,6 +51,7 @@ const SHELL = [ BASE + 'static/work-selection.js', BASE + 'static/today-work.js', BASE + 'static/today-timer.js', + BASE + 'static/today-lock-screen.js', BASE + 'static/today-session-sync.js', BASE + 'static/today-recap.js', BASE + 'static/today-wrap-up.js', @@ -419,6 +420,26 @@ self.addEventListener('sync', event => { if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify()); }); +async function updateTodayLockScreen(active, running) { + const tag = 'stackchain-today-session'; + if (!active) { + const notifications = await self.registration.getNotifications({ tag }); + notifications.forEach(notification => notification.close()); + return; + } + 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.', + tag, + renotify:false, + silent:true, + actions: [ + { action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' }, + { action:'open-today', title:'Open Today' }, + ], + data: { route:'#/my-work/today' }, + }); +} + self.addEventListener('message', event => { if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => { await issueSync.resume(); @@ -431,11 +452,15 @@ self.addEventListener('message', event => { try { await issueSync.purge(); await deletePrivateDatabases(); + await updateTodayLockScreen(false, false); event.ports?.[0]?.postMessage({ ok: true }); } catch (error) { event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') }); } })()); + if (event.data?.type === 'stackchain-today-lock-screen') { + event.waitUntil(updateTodayLockScreen(event.data.active === true, event.data.running === true)); + } }); self.addEventListener('push', event => { @@ -528,9 +553,35 @@ async function openCanonicalIssueUrl(rawUrl) { return client.focus(); } +async function applyTodayTimerAction(action) { + if (!['pause', 'resume'].includes(action)) 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 }); + return client.focus?.(); + } + const target = new URL(BASE + '?today_timer_action=' + action + route, self.location.origin).href; + return self.clients.openWindow(target); +} + self.addEventListener('notificationclick', event => { const route = String(event.notification.data?.route || ''); const issueUrl = String(event.notification.data?.url || ''); + if ( + event.notification.tag === 'stackchain-today-session' + && route === '#/my-work/today' + && ['pause-today', 'resume-today', 'open-today', ''].includes(event.action) + ) { + event.notification.close(); + if (event.action === 'pause-today' || event.action === 'resume-today') { + event.waitUntil(applyTodayTimerAction(event.action === 'pause-today' ? 'pause' : 'resume')); + } else { + event.waitUntil(openWorkRoute(route)); + } + return; + } if (issueUrl) { event.notification.close(); event.waitUntil(openCanonicalIssueUrl(issueUrl)); diff --git a/frontend/today-lock-screen.js b/frontend/today-lock-screen.js new file mode 100644 index 0000000..c2fb46b --- /dev/null +++ b/frontend/today-lock-screen.js @@ -0,0 +1,107 @@ +function createTodayLockScreen({ + storage, + getLogin, + serviceWorker, + NotificationRef, + control, + status, + locationRef, + historyRef, + onAction = () => {}, +}) { + const preferenceKey = () => { + const login = String(getLogin?.() || '').trim().toLowerCase(); + return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : ''; + }; + const supported = Boolean(serviceWorker && NotificationRef); + const enabled = () => { + const key = preferenceKey(); + return Boolean(key && storage?.getItem(key) === '1'); + }; + const setStatus = message => { + if (status) status.textContent = message; + }; + const post = async message => { + const target = serviceWorker?.controller || (await serviceWorker?.ready)?.active; + target?.postMessage?.(message); + }; + const hide = () => post({ + type:'stackchain-today-lock-screen', active:false, running:false, + }); + const render = () => { + if (control) { + control.checked = enabled(); + control.disabled = !supported; + } + if (!supported) setStatus('Lock-screen controls are not supported on this device.'); + else if (enabled()) setStatus('Lock-screen Today controls are on.'); + }; + const consumeAction = action => { + if (!enabled() || !['pause', 'resume'].includes(action)) return false; + onAction(action); + return true; + }; + const api = { + enabled, + async enable() { + if (!supported) { + render(); + return false; + } + const permission = NotificationRef.permission === 'granted' ? + 'granted' : await NotificationRef.requestPermission(); + if (permission !== 'granted') { + if (control) control.checked = false; + setStatus(permission === 'denied' ? + 'Lock-screen controls are blocked in browser settings.' : + 'Lock-screen controls were not enabled.'); + return false; + } + const key = preferenceKey(); + if (!key) return false; + storage?.setItem(key, '1'); + render(); + return true; + }, + async disable() { + const key = preferenceKey(); + if (key) storage?.removeItem(key); + if (control) control.checked = false; + setStatus('Lock-screen Today controls are off.'); + await hide(); + return true; + }, + async sync(snapshot, active) { + if (!enabled() || NotificationRef?.permission !== 'granted') return false; + const visible = Boolean(active && snapshot?.identity); + await post({ + type:'stackchain-today-lock-screen', + active:visible, + running:visible && Boolean(snapshot.running), + }); + return true; + }, + consumeLaunchAction() { + let url; + try { url = new URL(locationRef?.href || ''); } + catch (_error) { return false; } + const action = url.searchParams.get('today_timer_action'); + if (!['pause', 'resume'].includes(action)) return false; + url.searchParams.delete('today_timer_action'); + historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash); + return consumeAction(action); + }, + render, + }; + control?.addEventListener?.('change', () => { + if (control.checked) api.enable(); + else api.disable(); + }); + serviceWorker?.addEventListener?.('message', event => { + if (event.data?.type === 'stackchain-today-timer-action') consumeAction(String(event.data.action || '')); + }); + render(); + return api; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createTodayLockScreen; diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 076638b..f401e6b 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -34,7 +34,7 @@ FEATURE_SOURCES = { "security-center": ("static/security-center.js",), "today-timer": ( "static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", - "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js", + "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js", "static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index cc87091..50d52e9 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v118" in worker + assert "stackchain-dashboard-shell-v119" in worker diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 6c6ba0e..91c6cec 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index c405dc4..b1c7e04 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v118" in worker + assert "stackchain-dashboard-shell-v119" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 32a3ad3..aa835be 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v118" in worker + assert "stackchain-dashboard-shell-v119" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index c650b9f..f7d8195 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -223,7 +223,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v118" in worker + assert "stackchain-dashboard-shell-v119" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index 436d153..afbcf75 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -174,5 +174,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v118" in worker + assert "stackchain-dashboard-shell-v119" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 65ff718..e5f38d4 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v118" in service_worker + assert "stackchain-dashboard-shell-v119" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index d2f60d3..db6e898 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 2e61d7d..f541428 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; +const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; const storedResponses = new Map(); storedResponses.set( 'https://forge.example/dashboard/__offline-session-lease', @@ -73,7 +73,10 @@ const context = {{ matchAll: async () => state.clientList || [], openWindow: async url => {{ state.opened.push(url); }}, }}, - registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}}, + registration: {{ + showNotification: async (title, options) => state.notifications.push({{title,options}}), + getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}], + }}, __issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }}, purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }}, @@ -165,13 +168,13 @@ async function dispatchPush(payload) {{ def test_offline_activation_migration_rolls_the_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -180,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -189,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -197,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -212,7 +215,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -220,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -230,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -246,21 +249,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/update-ownership.js'" in source @@ -501,6 +504,91 @@ def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing( assert result["state"]["backgroundResumes"] == 1 +def test_active_today_message_replaces_one_privacy_safe_lock_screen_notification(): + result = run_worker_scenario( + """ + await dispatchMessage({type:'stackchain-today-lock-screen',active:true,running:true,identity:'secret/repo#42'}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["notifications"] == [ + { + "title": "Today session running", + "options": { + "body": "Your active Today timer is running.", + "tag": "stackchain-today-session", + "renotify": False, + "silent": True, + "actions": [ + {"action": "pause-today", "title": "Pause"}, + {"action": "open-today", "title": "Open Today"}, + ], + "data": {"route": "#/my-work/today"}, + }, + } + ] + assert "secret" not in json.dumps(result["notifications"]) + + +def test_inactive_today_message_closes_the_lock_screen_notification(): + result = run_worker_scenario( + """ + await dispatchMessage({type:'stackchain-today-lock-screen',active:false,running:false}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["notifications"] == [] + assert result["closedNotifications"] == 1 + + +def test_private_data_purge_also_removes_the_today_lock_screen_notification(): + result = run_worker_scenario( + """ + await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:()=>{}}]); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["outboxPurges"] == 1 + assert result["closedNotifications"] == 1 + + +def test_today_lock_screen_action_updates_an_open_dashboard_without_navigation(): + 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','pause-today',null,'stackchain-today-session'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["clientMessages"] == [ + {"type": "stackchain-today-timer-action", "action": "pause"} + ] + assert result["focused"] == ["today"] + assert result["opened"] == [] + + +def test_today_lock_screen_action_opens_a_validated_one_shot_route_when_closed(): + result = run_worker_scenario( + """ + await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["opened"] == [ + "https://forge.example/dashboard/?today_timer_action=resume#/my-work/today" + ] + + def test_background_mutation_abort_also_cancels_stalled_csrf_lookup(): result = run_worker_scenario( """ @@ -930,7 +1018,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/queue-today.js'" in source @@ -988,6 +1076,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/work-selection.js", "/dashboard/static/today-work.js", "/dashboard/static/today-timer.js", + "/dashboard/static/today-lock-screen.js", "/dashboard/static/today-session-sync.js", "/dashboard/static/today-recap.js", "/dashboard/static/today-wrap-up.js", diff --git a/tests/test_today_lock_screen.py b/tests/test_today_lock_screen.py new file mode 100644 index 0000000..6efa76b --- /dev/null +++ b/tests/test_today_lock_screen.py @@ -0,0 +1,117 @@ +import json +import subprocess +from pathlib import Path + + +SOURCE = Path(__file__).parents[1] / "frontend" / "today-lock-screen.js" +ROOT = SOURCE.parents[1] + + +def run_scenario(scenario: str) -> dict: + script = SOURCE.read_text() + r""" +const values = new Map(); +const messages = []; +const listeners = {}; +const control = {checked:false, disabled:false, addEventListener:(name, fn)=>listeners[name]=fn}; +const status = {textContent:''}; +const serviceWorker = { + controller:{postMessage:message=>messages.push(message)}, + ready:Promise.resolve({active:{postMessage:message=>messages.push(message)}}), + addEventListener:(name, fn)=>listeners['sw-' + name]=fn, +}; +const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +let permission = 'default'; +const NotificationRef = {get permission(){return permission;},requestPermission:async()=>{permission='granted';return permission;}}; +const actions = []; +const locationRef = {href:'https://forge.example/dashboard/#/my-work/today'}; +const historyRef = {replaceState:(_a,_b,url)=>{locationRef.href=new URL(url, locationRef.href).href;}}; +(async()=>{ + const lockScreen = createTodayLockScreen({storage,getLogin:()=> 'Timmy',serviceWorker,NotificationRef,control,status,locationRef,historyRef,onAction:action=>actions.push(action)}); + %SCENARIO% +})().catch(error=>{console.error(error);process.exit(1)}); +""".replace("%SCENARIO%", scenario) + completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(completed.stdout) + + +def test_operator_opt_in_syncs_one_privacy_safe_session_notification(): + result = run_scenario(r""" + await lockScreen.enable(); + await lockScreen.sync({identity:'issue:secret/repo:42:',running:true,elapsed_ms:125000}, true); + process.stdout.write(JSON.stringify({checked:control.checked,status:status.textContent,messages,stored:[...values.entries()]})); +""") + + assert result["checked"] is True + assert result["status"] == "Lock-screen Today controls are on." + assert result["stored"] == [["stackchain.today-lock-screen.v1.timmy", "1"]] + assert result["messages"][-1] == { + "type": "stackchain-today-lock-screen", + "active": True, + "running": True, + } + assert "secret" not in json.dumps(result) + + +def test_disabling_or_ending_session_removes_notification(): + result = run_scenario(r""" + await lockScreen.enable(); + await lockScreen.sync({identity:'issue:r:42:',running:false,elapsed_ms:0}, false); + await lockScreen.disable(); + process.stdout.write(JSON.stringify({checked:control.checked,messages,stored:[...values.entries()]})); +""") + + assert result["checked"] is False + assert result["stored"] == [] + assert result["messages"][-2:] == [ + {"type": "stackchain-today-lock-screen", "active": False, "running": False}, + {"type": "stackchain-today-lock-screen", "active": False, "running": False}, + ] + + +def test_valid_notification_action_is_consumed_once_and_removed_from_url(): + result = run_scenario(r""" + values.set('stackchain.today-lock-screen.v1.timmy','1'); + locationRef.href='https://forge.example/dashboard/?today_timer_action=pause#/my-work/today'; + const consumed = lockScreen.consumeLaunchAction(); + const second = lockScreen.consumeLaunchAction(); + listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'resume'}}); + process.stdout.write(JSON.stringify({consumed,second,actions,href:locationRef.href})); +""") + + assert result == { + "consumed": True, + "second": False, + "actions": ["pause", "resume"], + "href": "https://forge.example/dashboard/#/my-work/today", + } + + +def test_permission_denial_is_truthful_and_does_not_persist_opt_in(): + result = run_scenario(r""" + NotificationRef.requestPermission=async()=>{permission='denied';return permission;}; + const enabled = await lockScreen.enable(); + process.stdout.write(JSON.stringify({enabled,checked:control.checked,status:status.textContent,stored:[...values.entries()]})); +""") + + assert result == { + "enabled": False, + "checked": False, + "status": "Lock-screen controls are blocked in browser settings.", + "stored": [], + } + + +def test_lock_screen_flow_is_wired_into_the_packaged_today_journey(): + index = (ROOT / "frontend" / "index.html").read_text() + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + bundle = (ROOT / "src" / "frontend_bundle.py").read_text() + + assert 'id="today-lock-screen"' in index + assert 'id="today-lock-screen-status"' in index + assert '' in index + assert index.index('static/today-lock-screen.js') < index.index('static/dashboard.js') + assert 'createTodayLockScreen({' in dashboard + assert "action === 'pause' ? timer.pause() : timer.resume()" in dashboard + assert "todayLockScreen.sync(snapshot, workSession.checkpointed())" in dashboard + assert "todayLockScreen.consumeLaunchAction()" in dashboard + assert '"static/today-lock-screen.js"' in bundle diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 2e2dcf3..c53bdc8 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v118';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v119';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index c170b0d..7ebe7e6 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v118" in source + assert "stackchain-dashboard-shell-v119" in source assert "BASE + 'static/today-sync.js'" in source