From 2bd20130b6bfa9b6f10d3580850cc3195e870be6 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 00:01:38 +0000 Subject: [PATCH] feat: snooze mobile deadline reminders (Closes #1102) --- frontend/service-worker.js | 28 ++++- src/main.py | 17 +++ src/push_notifications.py | 36 ++++-- src/push_subscription_store.py | 35 +++++- tests/test_comment_next.py | 2 +- tests/test_deadline_reminders.py | 133 ++++++++++++++++++++++ tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_push_notifications.py | 29 +++++ tests/test_service_worker.py | 79 ++++++++++--- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 17 files changed, 333 insertions(+), 44 deletions(-) diff --git a/frontend/service-worker.js b/frontend/service-worker.js index b853de3..6e30a5c 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-v120'; +const CACHE = 'stackchain-dashboard-shell-v121'; 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; @@ -512,7 +512,7 @@ self.addEventListener('push', event => { tag, actions: [ { action: 'protect-today', title: 'Protect Today' }, - { action: 'open-agenda', title: 'Open Agenda' }, + { action: 'snooze-deadline', title: 'Remind in 1 hour' }, ], data: {route, protectRoute}, } @@ -630,10 +630,26 @@ self.addEventListener('notificationclick', event => { event.waitUntil(openWorkRoute(protectRoute)); return; } - if (event.action === 'open-agenda') { - if (route !== '#/my-work/agenda') return; - event.notification.close(); - event.waitUntil(openWorkRoute(route)); + if (event.action === 'snooze-deadline') { + if ( + route !== '#/my-work/agenda' + || !/^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(event.notification.tag) + ) return; + event.waitUntil((async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS); + try { + await fetchJson(BASE + 'api/v1/push-subscription/deadlines/snooze', { + method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal, + }); + event.notification.close(); + } catch (_error) { + await openWorkRoute(route); + event.notification.close(); + } finally { + clearTimeout(timeout); + } + })()); return; } if (event.action === 'tomorrow') { diff --git a/src/main.py b/src/main.py index 569e169..41fccf6 100644 --- a/src/main.py +++ b/src/main.py @@ -2236,6 +2236,23 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R } +@app.patch("/api/v1/push-subscription/deadlines/snooze") +async def snooze_deadline_reminder(request: Request): + device_id = await dashboard_auth.session_management_id( + request.state.dashboard_session + ) + now = int(time.time()) + snoozed = await asyncio.to_thread( + _push_subscription_store.snooze_deadline_reminder, + device_id, + now=now, + delay_seconds=3_600, + ) + if not snoozed: + raise HTTPException(status_code=409, detail="Enable deadline reminders first") + return {"snoozed": True, "snoozed_until": now + 3_600} + + @app.post("/api/v1/session/activity") async def record_session_activity(request: Request): session = request.state.dashboard_session diff --git a/src/push_notifications.py b/src/push_notifications.py index ecc7bfc..2cb8838 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -348,10 +348,16 @@ async def _dispatch_deadline_reminders_unlocked( local_now = current.astimezone(ZoneInfo(device.timezone)) except ZoneInfoNotFoundError: continue - if ( - local_now.hour >= device.reminder_hour + snooze_due = ( + device.snoozed_until is not None + and device.snoozed_until <= current.timestamp() + ) + daily_due = ( + device.snoozed_until is None + and local_now.hour >= device.reminder_hour and device.delivered_local_day != local_now.date().isoformat() - ): + ) + if snooze_due or daily_due: eligible_devices.append(device) if not eligible_devices: return 0 @@ -369,6 +375,11 @@ async def _dispatch_deadline_reminders_unlocked( continue due_days.append(due_day) if not due_days: + await asyncio.gather(*( + asyncio.to_thread(store.clear_deadline_snooze, device.session_id) + for device in eligible_devices + if device.snoozed_until is not None + )) return 0 due_counts = {} for device in eligible_devices: @@ -377,6 +388,11 @@ async def _dispatch_deadline_reminders_unlocked( due_count = sum(due_day <= local_cutoff for due_day in due_days) if due_count: due_counts[device.session_id] = due_count + await asyncio.gather(*( + asyncio.to_thread(store.clear_deadline_snooze, device.session_id) + for device in eligible_devices + if device.snoozed_until is not None and device.session_id not in due_counts + )) eligible_devices = [ device for device in eligible_devices if device.session_id in due_counts ] @@ -408,10 +424,16 @@ async def _dispatch_deadline_reminders_unlocked( except ZoneInfoNotFoundError: return 0 local_day = local_now.date().isoformat() - if ( - local_now.hour < device.reminder_hour - or device.delivered_local_day == local_day - ): + snooze_due = ( + device.snoozed_until is not None + and device.snoozed_until <= current.timestamp() + ) + daily_due = ( + device.snoozed_until is None + and local_now.hour >= device.reminder_hour + and device.delivered_local_day != local_day + ) + if not (snooze_due or daily_due): return 0 due_count = due_counts[device.session_id] still_owner = await asyncio.to_thread( diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py index b42ee53..be8524d 100644 --- a/src/push_subscription_store.py +++ b/src/push_subscription_store.py @@ -31,6 +31,7 @@ class DeadlineReminderDevice: reminder_hour: int reminder_days: int delivered_local_day: str | None + snoozed_until: float | None def _revisions( @@ -94,6 +95,7 @@ class PushSubscriptionStore: reminder_hour INTEGER NOT NULL DEFAULT 9, reminder_days INTEGER NOT NULL DEFAULT 2, delivered_local_day TEXT, + snoozed_until REAL, FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) ON DELETE CASCADE ); @@ -140,6 +142,10 @@ class PushSubscriptionStore: connection.execute( "ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2" ) + if "snoozed_until" not in preference_columns: + connection.execute( + "ALTER TABLE push_deadline_preferences ADD COLUMN snoozed_until REAL" + ) def _connect(self): connection = connect_private_sqlite(self.path, timeout=2) connection.execute("PRAGMA foreign_keys = ON") @@ -233,20 +239,43 @@ class PushSubscriptionStore: with self._connect() as connection: rows = connection.execute( """SELECT s.session_id, s.subscription_json, p.timezone, - p.reminder_hour, p.reminder_days, p.delivered_local_day + p.reminder_hour, p.reminder_days, p.delivered_local_day, + p.snoozed_until FROM push_subscriptions s JOIN push_deadline_preferences p ON p.session_id = s.session_id WHERE p.enabled = 1 ORDER BY s.session_id""" ).fetchall() return [ - DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5]) + DeadlineReminderDevice( + row[0], json.loads(row[1]), row[2], row[3], row[4], row[5], row[6] + ) for row in rows ] + def snooze_deadline_reminder( + self, session_id: str, *, now: float, delay_seconds: int = 3_600 + ) -> bool: + with self._connect() as connection: + result = connection.execute( + """UPDATE push_deadline_preferences SET snoozed_until = ? + WHERE session_id = ? AND enabled = 1""", + (now + delay_seconds, session_id), + ) + return result.rowcount == 1 + + def clear_deadline_snooze(self, session_id: str) -> None: + with self._connect() as connection: + connection.execute( + """UPDATE push_deadline_preferences SET snoozed_until = NULL + WHERE session_id = ?""", + (session_id,), + ) + def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None: with self._connect() as connection: connection.execute( - """UPDATE push_deadline_preferences SET delivered_local_day = ? + """UPDATE push_deadline_preferences + SET delivered_local_day = ?, snoozed_until = NULL WHERE session_id = ? AND enabled = 1""", (local_day, session_id), ) diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 57765c3..a114934 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-v120" in worker + assert "stackchain-dashboard-shell-v121" in worker diff --git a/tests/test_deadline_reminders.py b/tests/test_deadline_reminders.py index 8262eb4..342ecc5 100644 --- a/tests/test_deadline_reminders.py +++ b/tests/test_deadline_reminders.py @@ -55,6 +55,110 @@ async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplic assert "private/repo" not in json.dumps(sent) +@pytest.mark.anyio +async def test_snoozed_deadline_revalidates_and_sends_once_after_expiry(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("device-a", { + "endpoint": "https://push.example/device-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_deadline_preferences( + "device-a", enabled=True, timezone="UTC", reminder_hour=9 + ) + store.mark_deadline_reminder_delivered("device-a", "2026-08-13") + snoozed_at = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc) + store.snooze_deadline_reminder( + "device-a", now=snoozed_at.timestamp(), delay_seconds=3_600 + ) + snapshots = 0 + sent = [] + + async def assigned(): + nonlocal snapshots + snapshots += 1 + return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14"}]} + + async def send(_subscription, payload): + sent.append(json.loads(payload)) + + config = PushConfiguration("public", "private", "mailto:ops@example.com") + assert await dispatch_deadline_reminders( + store, config, assigned, send, + now=datetime(2026, 8, 13, 10, 59, tzinfo=timezone.utc), + ) == 0 + assert snapshots == 0 + + assert await dispatch_deadline_reminders( + store, config, assigned, send, + now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc), + ) == 1 + assert snapshots == 1 + assert sent[0]["deadline_count"] == 1 + assert sent[0]["tag"] == "stackchain-deadline-digest-2026-08-13" + assert store.deadline_reminder_devices()[0].snoozed_until is None + assert await dispatch_deadline_reminders( + store, config, assigned, send, + now=datetime(2026, 8, 13, 11, 1, tzinfo=timezone.utc), + ) == 0 + assert snapshots == 1 + + +@pytest.mark.anyio +async def test_expired_deadline_snooze_clears_when_no_deadlines_remain(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("device-a", { + "endpoint": "https://push.example/device-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_deadline_preferences( + "device-a", enabled=True, timezone="UTC", reminder_hour=9 + ) + store.mark_deadline_reminder_delivered("device-a", "2026-08-13") + store.snooze_deadline_reminder( + "device-a", + now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(), + ) + + async def resolved(): + return {"complete": True, "items": []} + + assert await dispatch_deadline_reminders( + store, + PushConfiguration("public", "private", "mailto:ops@example.com"), + resolved, + now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc), + ) == 0 + assert store.deadline_reminder_devices()[0].snoozed_until is None + + +@pytest.mark.anyio +async def test_expired_deadline_snooze_clears_when_deadlines_move_beyond_horizon(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("device-a", { + "endpoint": "https://push.example/device-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_deadline_preferences( + "device-a", enabled=True, timezone="UTC", reminder_hour=9, reminder_days=2 + ) + store.mark_deadline_reminder_delivered("device-a", "2026-08-13") + store.snooze_deadline_reminder( + "device-a", + now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(), + ) + + async def replanned(): + return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-20"}]} + + assert await dispatch_deadline_reminders( + store, + PushConfiguration("public", "private", "mailto:ops@example.com"), + replanned, + now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc), + ) == 0 + assert store.deadline_reminder_devices()[0].snoozed_until is None + + @pytest.mark.anyio async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") @@ -240,6 +344,35 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa } +def test_deadline_snooze_is_device_bound_and_requires_enabled_reminders(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + for session_id in ("enabled", "disabled"): + store.upsert(session_id, { + "endpoint": f"https://push.example/{session_id}", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_deadline_preferences( + "enabled", enabled=True, timezone="UTC", reminder_hour=9 + ) + store.set_deadline_preferences( + "disabled", enabled=False, timezone="UTC", reminder_hour=9 + ) + + assert store.snooze_deadline_reminder( + "enabled", now=1_765_000_000, delay_seconds=3_600 + ) is True + assert store.snooze_deadline_reminder( + "disabled", now=1_765_000_000, delay_seconds=3_600 + ) is False + assert store.snooze_deadline_reminder( + "missing", now=1_765_000_000, delay_seconds=3_600 + ) is False + + devices = {device.session_id: device for device in store.deadline_reminder_devices()} + assert devices["enabled"].snoozed_until == 1_765_003_600 + assert "disabled" not in devices + + def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path): database = tmp_path / "push.sqlite3" import sqlite3 diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 699a92a..bb1292c 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-v120" in source + assert "stackchain-dashboard-shell-v121" 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 a59cb45..279709a 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-v120" in worker + assert "stackchain-dashboard-shell-v121" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index d1d3e3d..17038e0 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-v120" in worker + assert "stackchain-dashboard-shell-v121" 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 98d86f8..74e474a 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -265,7 +265,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-v120" in worker + assert "stackchain-dashboard-shell-v121" 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 a77d281..a049c6a 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,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-v120" in worker + assert "stackchain-dashboard-shell-v121" 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 2c0cbc3..f527684 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-v120" in service_worker + assert "stackchain-dashboard-shell-v121" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 2ad3819..bbd9772 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-v120" in source + assert "stackchain-dashboard-shell-v121" 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_notifications.py b/tests/test_push_notifications.py index 08856fd..5e0d9e6 100644 --- a/tests/test_push_notifications.py +++ b/tests/test_push_notifications.py @@ -1090,6 +1090,35 @@ async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monk assert store.deadline_preferences("session-a")["reminder_days"] == 7 +@pytest.mark.anyio +async def test_authenticated_device_can_snooze_its_enabled_deadline_reminder(tmp_path, monkeypatch): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + for session_id in ("session-a", "session-b"): + store.upsert(session_id, { + "endpoint": f"https://push.example/{session_id}", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_deadline_preferences( + session_id, enabled=True, timezone="UTC", reminder_hour=9 + ) + monkeypatch.setattr(main, "_push_subscription_store", store) + monkeypatch.setattr(main.time, "time", lambda: 1_765_000_000) + + async def management_id(_session): + return "session-a" + + monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id) + request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object())) + + assert await main.snooze_deadline_reminder(request) == { + "snoozed": True, + "snoozed_until": 1_765_003_600, + } + devices = {device.session_id: device for device in store.deadline_reminder_devices()} + assert devices["session-a"].snoozed_until == 1_765_003_600 + assert devices["session-b"].snoozed_until is None + + @pytest.mark.anyio async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch): store = PushSubscriptionStore(tmp_path / "push.sqlite3") diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 9475cbb..358b2f4 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -168,13 +168,13 @@ async function dispatchPush(payload) {{ def test_offline_activation_migration_rolls_the_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v120" in source + assert "stackchain-dashboard-shell-v121" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v120" in source + assert "stackchain-dashboard-shell-v121" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -183,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-v120" in source + assert "stackchain-dashboard-shell-v121" 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 @@ -192,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-v120" in source + assert "stackchain-dashboard-shell-v121" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -200,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-v120" in source + assert "stackchain-dashboard-shell-v121" 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-v120" in source + assert "stackchain-dashboard-shell-v121" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -215,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-v120" in source + assert "stackchain-dashboard-shell-v121" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -223,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-v120" in source + assert "stackchain-dashboard-shell-v121" 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 @@ -233,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-v120" in source + assert "stackchain-dashboard-shell-v121" 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-v120" in source + assert "stackchain-dashboard-shell-v121" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -249,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-v120" in source + assert "stackchain-dashboard-shell-v121" 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-v120" in source + assert "stackchain-dashboard-shell-v121" 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-v120" in source + assert "stackchain-dashboard-shell-v121" in source assert "BASE + 'static/update-ownership.js'" in source @@ -850,7 +850,7 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c assert "must-not-render" not in json.dumps(result["notifications"]) -def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_private_copy(): +def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy(): result = run_worker_scenario( """ await dispatchPush({ @@ -870,7 +870,7 @@ def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_ "tag": "stackchain-deadline-digest-2026-08-13", "actions": [ {"action": "protect-today", "title": "Protect Today"}, - {"action": "open-agenda", "title": "Open Agenda"}, + {"action": "snooze-deadline", "title": "Remind in 1 hour"}, ], "data": { "route": "#/my-work/agenda", @@ -884,19 +884,62 @@ def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_ assert "must-not-render" not in json.dumps(result["notifications"]) -def test_deadline_digest_open_agenda_action_preserves_browsing_flow(): +def test_deadline_digest_snooze_action_records_device_bound_reminder_without_opening_app(): result = run_worker_scenario( """ await dispatchPush({ tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', protect_route:'#/my-work/agenda/protect-today', deadline_count:1, }); - await dispatchNotificationClick('#/my-work/agenda', 'open-agenda', null, 'stackchain-deadline-digest-2026-08-13'); + const calls = []; + context.fetch = async (request, options = {}) => { + const url = String(request.url || request); + const headers = new Headers(options.headers || {}); + calls.push({url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token')}); + if (url.endsWith('/api/v1/session')) { + return new Response(JSON.stringify({csrf_token:'session-proof'}), { + status:200, headers:{'Content-Type':'application/json'}, + }); + } + return new Response(JSON.stringify({snoozed:true}), { + status:200, headers:{'Content-Type':'application/json'}, + }); + }; + await dispatchNotificationClick('#/my-work/agenda', 'snooze-deadline', null, 'stackchain-deadline-digest-2026-08-13'); + process.stdout.write(JSON.stringify({state,calls})); +""" + ) + + assert result["calls"] == [ + { + "url": "https://forge.example/dashboard/api/v1/session", + "method": "GET", + "csrf": None, + }, + { + "url": "https://forge.example/dashboard/api/v1/push-subscription/deadlines/snooze", + "method": "PATCH", + "csrf": "session-proof", + }, + ] + assert result["state"]["notificationClosed"] is True + assert result["state"]["opened"] == [] + + +def test_deadline_digest_failed_snooze_opens_agenda_instead_of_losing_reminder(): + result = run_worker_scenario( + """ + context.fetch = async () => { throw new Error('offline'); }; + await dispatchNotificationClick( + '#/my-work/agenda', 'snooze-deadline', null, + 'stackchain-deadline-digest-2026-08-13' + ); process.stdout.write(JSON.stringify(state)); """ ) assert result["opened"] == ["https://forge.example/dashboard/#/my-work/agenda"] + assert result["notificationClosed"] is True def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app(): @@ -1094,7 +1137,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-v120" in source + assert "stackchain-dashboard-shell-v121" 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 cfe86d5..22f0897 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-v120';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v121';" 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 41db9ab..530804f 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -191,7 +191,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-v120" in source + assert "stackchain-dashboard-shell-v121" in source assert "BASE + 'static/today-sync.js'" in source