diff --git a/README.md b/README.md index dd58f76..409c3a5 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,8 @@ export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com' export STACKCHAIN_PUSH_POLL_SECONDS=30 # Unread updates and deadline reminders run on independent, fixed-cadence # workers, so a slow channel cannot delay the other or add drift to its ticks. +# On each phone, Device Setup can enable deadline reminders and choose any local +# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored. export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10 export STACKCHAIN_PUSH_MAX_CONCURRENCY=8 # Maximum individual alerts per device and poll before one digest covers the rest. diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 250cb0c..1a438d8 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -38,6 +38,9 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid # .device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; } .device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; } .device-setup-step p { margin:5px 0 0; } +.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; } +.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; } +.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour { min-height:44px; } .device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; } .device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; } .device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 75ed01a..7264658 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -6037,6 +6037,23 @@ renderMyWork(); if (workSession.active()) workSession.reconcile(); }); + let pushController = null; + for (const selector of [qs('#push-deadline-hour'), qs('#device-setup-deadline-hour')]) { + for (let hour = 0; hour < 24; hour += 1) { + const option = document.createElement('option'); + option.value = String(hour); + option.textContent = `${String(hour).padStart(2, '0')}:00`; + selector.appendChild(option); + } + selector.value = '9'; + } + qs('#push-deadline-hour').addEventListener('change', event => { + qs('#device-setup-deadline-hour').value = event.target.value; + if (qs('#push-deadlines').checked) pushController?.changeDeadline(); + }); + qs('#device-setup-deadline-hour').addEventListener('change', event => { + qs('#push-deadline-hour').value = event.target.value; + }); let pushControllerReady = Promise.resolve(null); if ('serviceWorker' in navigator) { pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => { @@ -6046,11 +6063,14 @@ status:qs('#push-update-status'), deadlineControl:qs('#push-deadlines'), deadlineStatus:qs('#push-deadline-status'), + deadlineHour:qs('#push-deadline-hour'), notification:window.Notification, serviceWorker:navigator.serviceWorker, fetchJson:fetchReviewJson, }); await controller.init(); + pushController = controller; + qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value; return controller; }).catch(error => { qs('#push-updates').disabled = true; @@ -6093,6 +6113,14 @@ qs('#push-updates').checked = true; await controller.change(); }, + deadlineReadiness:() => pushController?.deadlineReadiness() + || {state:'unavailable', detail:'Deadline reminders are unavailable.'}, + enableDeadline:async () => { + const controller = await pushControllerReady; + if (!controller) return; + qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value; + await controller.enableDeadline(); + }, }); await deviceSetup.start(); return deviceSetup; diff --git a/frontend/index.html b/frontend/index.html index 27cc15d..3f3de8c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -82,6 +82,14 @@
Notify me about new updates

+
  • +
    Remind me about deadlines

    +
    + + + +
    +
  • @@ -130,6 +138,7 @@ + diff --git a/frontend/mobile-device-setup.js b/frontend/mobile-device-setup.js index fdd7f3e..a0ea14a 100644 --- a/frontend/mobile-device-setup.js +++ b/frontend/mobile-device-setup.js @@ -8,6 +8,7 @@ ['install', options.installButton, options.installStatus, options.install], ['offline', options.offlineButton, options.offlineStatus, options.enableOffline], ['push', options.pushButton, options.pushStatus, options.enablePush], + ['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline], ]; let trigger = options.launcher; @@ -100,8 +101,10 @@ function mountMobileDeviceSetup(options) { launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'), sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'), offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'), + deadlineButton:qs('#device-setup-deadline'), installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'), - pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'), + pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'), + readyStatus:qs('#device-setup-ready-status'), promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'), promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'), promptStorage:options.promptStorage, @@ -118,9 +121,11 @@ function mountMobileDeviceSetup(options) { : qs('#push-updates').checked ? {state:'complete', detail:'New update notifications are enabled.'} : {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'}, + deadline:options.deadlineReadiness(), }), install:() => options.installApp.install(), enableOffline:options.enableOffline, enablePush:options.enablePush, + enableDeadline:options.enableDeadline, }); } diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js index 7625576..a89f1be 100644 --- a/frontend/push-notifications.js +++ b/frontend/push-notifications.js @@ -2,10 +2,23 @@ if (typeof module === 'object' && module.exports) module.exports = factory; else root.createPushNotifications = factory; })(typeof self !== 'undefined' ? self : this, function createPushNotifications({ - control, status, deadlineControl, deadlineStatus, notification, serviceWorker, fetchJson, + control, status, deadlineControl, deadlineStatus, deadlineHour, notification, serviceWorker, fetchJson, }) { let configuration = null; + function formattedHour(value) { + return `${String(Number(value)).padStart(2, '0')}:00`; + } + + function deadlineReadiness() { + if (!configuration?.available || deadlineControl?.disabled) { + return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'}; + } + return configuration.deadline_enabled + ? {state:'complete', detail:`Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`} + : {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'}; + } + function applicationServerKey(value) { const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4); if (typeof atob === 'function') { @@ -25,12 +38,12 @@ if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.'; } - async function enable() { + async function ensureSubscription() { const permission = await notification.requestPermission(); if (permission !== 'granted') { control.checked = false; status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.'; - return; + return null; } const registration = await serviceWorker.ready; let subscription = await registration.pushManager.getSubscription(); @@ -47,6 +60,12 @@ }); control.checked = true; status.textContent = 'New update notifications enabled for this device.'; + configuration.subscribed = true; + return subscription; + } + + async function enable() { + await ensureSubscription(); } async function change() { @@ -64,31 +83,45 @@ async function changeDeadline() { deadlineControl.disabled = true; + if (deadlineHour) deadlineHour.disabled = true; try { const registration = await serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); + let subscription = await registration.pushManager.getSubscription(); + if (deadlineControl.checked && !subscription) subscription = await ensureSubscription(); if (deadlineControl.checked && !subscription) { deadlineControl.checked = false; - deadlineStatus.textContent = 'Enable new update notifications first.'; - return; + deadlineStatus.textContent = status.textContent; + return false; } const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9); await fetchJson('api/v1/push-subscription/deadlines', { method:'PUT', headers:{'Content-Type':'application/json'}, - body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:9}), + body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour}), }); + configuration.deadline_enabled = deadlineControl.checked; + configuration.reminder_hour = reminderHour; + configuration.timezone = timezone; deadlineStatus.textContent = deadlineControl.checked - ? 'Deadline reminders enabled for 9:00 local time.' + ? `Deadline reminders enabled for ${formattedHour(reminderHour)} local time.` : 'Deadline reminders are off for this device.'; + return true; } catch (error) { deadlineControl.checked = !deadlineControl.checked; deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.'; + return false; } finally { deadlineControl.disabled = false; + if (deadlineHour) deadlineHour.disabled = false; } } + async function enableDeadline() { + deadlineControl.checked = true; + return changeDeadline(); + } + async function init() { if (!control || !notification || !serviceWorker) return; control.addEventListener('change', change); @@ -102,13 +135,14 @@ } control.checked = Boolean(configuration.subscribed); if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled); + if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9); status.textContent = configuration.subscribed ? 'New update notifications enabled for this device.' : 'New update notifications are off for this device.'; if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled - ? `Deadline reminders enabled for ${configuration.reminder_hour}:00 local time.` + ? `Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.` : 'Deadline reminders are off for this device.'; } - return {init, change, changeDeadline}; + return {init, change, changeDeadline, enableDeadline, deadlineReadiness}; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 4d814e9..5757aa9 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v98'; +const CACHE = 'stackchain-dashboard-shell-v99'; 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; diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index f7ac843..842e83a 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-v98" in worker + assert "stackchain-dashboard-shell-v99" in worker diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index ab3bac1..ed25483 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path): worker = changed_frontend / "service-worker.js" worker.write_text( worker.read_text().replace( - "const CACHE = 'stackchain-dashboard-shell-v98';", + "const CACHE = 'stackchain-dashboard-shell-v99';", "const CACHE = 'stackchain-dashboard-shell-v999';", ) ) diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 4f5dafe..f935d93 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,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-v98" in source + assert "stackchain-dashboard-shell-v99" 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 dbffa7b..4b4fd70 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,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-v98" in worker + assert "stackchain-dashboard-shell-v99" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 29a0e9c..b74a21d 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-v98" in worker + assert "stackchain-dashboard-shell-v99" 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 0efe8d5..b00e141 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -15,16 +15,18 @@ class FakeTarget { async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); } focus() { state.focused = this; } } -const state = {installCalls:0, offlineCalls:0, pushCalls:0, focused:null}; +const state = {installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls:0, focused:null}; const launcher = new FakeTarget(); const closeButton = new FakeTarget(); const sheet = new FakeTarget(); sheet.hidden = true; const installButton = new FakeTarget(); const offlineButton = new FakeTarget(); const pushButton = new FakeTarget(); +const deadlineButton = new FakeTarget(); const installStatus = new FakeTarget(); const offlineStatus = new FakeTarget(); const pushStatus = new FakeTarget(); +const deadlineStatus = new FakeTarget(); const readyStatus = new FakeTarget(); const escapeTarget = new FakeTarget(); const promptCard = new FakeTarget(); promptCard.hidden = true; @@ -41,16 +43,18 @@ let readiness = { install:{state:'complete', detail:'Stackchain is installed.'}, offline:{state:'incomplete', detail:'Offline work is off.'}, push:{state:'unavailable', detail:'Notifications are unavailable.'}, + deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'}, }; const setup = createMobileDeviceSetup({ - launcher, closeButton, sheet, installButton, offlineButton, pushButton, - installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget, + launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton, + installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget, promptCard, promptSummary, promptLauncher, promptDismiss, promptStorage, now:() => now, getReadiness:() => readiness, install:async () => { state.installCalls += 1; }, enableOffline:async () => { state.offlineCalls += 1; }, enablePush:async () => { state.pushCalls += 1; }, + enableDeadline:async () => { state.deadlineCalls += 1; }, }); (async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); }); """.replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script) @@ -166,6 +170,29 @@ process.stdout.write(JSON.stringify({ assert result == {"hidden": True, "launcherFocused": True} +def test_deadline_step_runs_one_setup_action_and_uses_confirmed_readiness(): + result = run_scenario(""" +readiness.push = {state:'complete', detail:'New update notifications are enabled.'}; +readiness.deadline = {state:'incomplete', detail:'Choose when to receive deadline reminders.'}; +await launcher.dispatch('click', {currentTarget:launcher}); +readiness.deadline = {state:'complete', detail:'Deadline reminders enabled for 08:00 local time.'}; +await deadlineButton.dispatch('click'); +process.stdout.write(JSON.stringify({ + calls:state.deadlineCalls, + detail:deadlineStatus.textContent, + hidden:deadlineButton.hidden, + summary:readyStatus.textContent, +})); +""") + + assert result == { + "calls": 1, + "detail": "Deadline reminders enabled for 08:00 local time.", + "hidden": True, + "summary": "3 of 4 available steps ready.", + } + + def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): root = MODULE.parents[1] html = (root / "frontend" / "index.html").read_text() @@ -180,13 +207,14 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"') assert 'id="device-setup-sheet"' in html assert 'aria-labelledby="device-setup-heading"' in html - assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push")) + assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline")) + assert 'id="device-setup-deadline-hour"' in html assert '' in html assert "createMobileDeviceSetup.mount({" in dashboard assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v98" in worker + assert "stackchain-dashboard-shell-v99" 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_plan_today.py b/tests/test_plan_today.py index 1526e23..e5b128d 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-v98" in source + assert "stackchain-dashboard-shell-v99" 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_push_frontend.py b/tests/test_push_frontend.py index a64c316..5379e36 100644 --- a/tests/test_push_frontend.py +++ b/tests/test_push_frontend.py @@ -18,6 +18,7 @@ const deadlineControl = { checked:false, disabled:false, addEventListener:(_name, callback) => state.deadlineChange = callback, }; +const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback}; const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }}; const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }}; const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }}; @@ -26,7 +27,7 @@ const registration = {pushManager:{ subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; }, }}; const feature = createPushNotifications({ - control, status, deadlineControl, deadlineStatus, + control, status, deadlineControl, deadlineStatus, deadlineHour, notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }}, serviceWorker: {ready:Promise.resolve(registration)}, fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; }, @@ -79,7 +80,53 @@ process.stdout.write(JSON.stringify(state)); assert body["enabled"] is True assert body["reminder_hour"] == 9 assert isinstance(body["timezone"], str) and body["timezone"] - assert result["deadlineText"] == "Deadline reminders enabled for 9:00 local time." + assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time." + + +def test_deadline_setup_subscribes_once_and_persists_selected_local_hour(): + result = run_scenario(""" +state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'}; +await feature.init(); +deadlineHour.value = '8'; +await feature.enableDeadline(); +process.stdout.write(JSON.stringify(state)); +""") + + assert result["prompts"] == 1 + assert len(result["subscriptions"]) == 1 + assert [request[0] for request in result["requests"][-2:]] == [ + "api/v1/push-subscription", "api/v1/push-subscription/deadlines", + ] + body = json.loads(result["requests"][-1][2]) + assert body["enabled"] is True + assert body["reminder_hour"] == 8 + assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time." + + +def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete(): + restored = run_scenario(""" +state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,public_key:'AQID'}; +state.current = existing; +await feature.init(); +process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineControl.checked, readiness:feature.deadlineReadiness()})); +""") + assert restored == { + "hour": "17", "checked": True, + "readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time."}, + } + + denied = run_scenario(""" +state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'}; +state.permission = 'denied'; +await feature.init(); +deadlineHour.value = '8'; +await feature.enableDeadline(); +process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests})); +""") + assert denied["checked"] is False + assert denied["readiness"]["state"] == "incomplete" + assert "blocked" in denied["readiness"]["detail"].lower() + assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"] def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller(): @@ -94,6 +141,7 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller(): assert 'id="push-updates"' in html assert 'id="push-update-status"' in html assert 'id="push-deadlines"' in html + assert 'id="push-deadline-hour"' in html assert 'id="push-deadline-status"' in html assert '' in html assert "createPushNotifications({" in dashboard diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 18fba36..dd01c63 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -145,7 +145,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v98" in source + assert "stackchain-dashboard-shell-v99" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v98" in source + assert "stackchain-dashboard-shell-v99" 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-v98" in source + assert "stackchain-dashboard-shell-v99" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -169,7 +169,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-v98" in source + assert "stackchain-dashboard-shell-v99" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v98" in source + assert "stackchain-dashboard-shell-v99" 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-v98" in source + assert "stackchain-dashboard-shell-v99" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -193,21 +193,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-v98" in source + assert "stackchain-dashboard-shell-v99" 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-v98" in source + assert "stackchain-dashboard-shell-v99" 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-v98" in source + assert "stackchain-dashboard-shell-v99" in source assert "BASE + 'static/update-ownership.js'" in source @@ -680,7 +680,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-v98" in source + assert "stackchain-dashboard-shell-v99" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 687a3e9..fb5eb46 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-v98';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v99';" 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 85debdb..18829aa 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-v98" in source + assert "stackchain-dashboard-shell-v99" in source assert "BASE + 'static/today-sync.js'" in source