diff --git a/frontend/dashboard.js b/frontend/dashboard.js index bffbdf7..97465c6 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -6058,10 +6058,27 @@ if (!preserveRoute) workRoute.queue(filter); return true; } - function openWorkQueueRoute(filter) { + async function openWorkQueueRoute(filter, action = null) { if (!selectWorkQueue(filter, { preserveRoute:true })) return; qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); + if (action !== 'protect-today') return; + await completeAgendaIssues(); + if (workPagination.issue?.has_more) { + qs('#my-work-action-status').textContent = + 'Protect Today needs all assigned deadlines. Retry when connected.'; + return; + } + const proposal = protectToday.propose({ + agenda:agendaMyWork(activeMyWork), today:todayMyWork, + identity:item => todayWork.identity(item), limit:todayWork.limit, + }); + if (!proposal.protected.length) { + qs('#protect-today-status').textContent = 'No overdue or due-today work needs protection.'; + return; + } + pendingProtectToday = proposal; + openPlanToday(qs('#protect-today')); } qs('#work-milestone-filter').addEventListener('change', event => { selectedWorkMilestone = event.target.value; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 9ea40cc..2061ec2 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -314,12 +314,14 @@ self.addEventListener('push', event => { try { payload = event.data?.json?.() || {}; } catch (_error) { return; } const route = String(payload.route || ''); + const protectRoute = String(payload.protect_route || ''); const tag = String(payload.tag || ''); const notificationId = Number(payload.notification_id); const updateCount = Number(payload.update_count); const deadlineCount = Number(payload.deadline_count); if ( route === '#/my-work/agenda' + && protectRoute === '#/my-work/agenda/protect-today' && /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag) && Number.isSafeInteger(deadlineCount) && deadlineCount > 0 @@ -330,7 +332,11 @@ self.addEventListener('push', event => { { body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'), tag, - data: {route}, + actions: [ + { action: 'protect-today', title: 'Protect Today' }, + { action: 'open-agenda', title: 'Open Agenda' }, + ], + data: {route, protectRoute}, } )); return; @@ -380,6 +386,22 @@ async function openWorkRoute(route) { self.addEventListener('notificationclick', event => { const route = String(event.notification.data?.route || ''); + if (event.action === 'protect-today') { + const protectRoute = String(event.notification.data?.protectRoute || route); + if ( + protectRoute !== '#/my-work/agenda/protect-today' + || !['#/my-work/agenda', protectRoute].includes(route) + ) return; + event.notification.close(); + event.waitUntil(openWorkRoute(protectRoute)); + return; + } + if (event.action === 'open-agenda') { + if (route !== '#/my-work/agenda') return; + event.notification.close(); + event.waitUntil(openWorkRoute(route)); + return; + } if (event.action === 'tomorrow') { const notificationId = Number(event.notification.data?.notificationId); if ( diff --git a/frontend/work-route.js b/frontend/work-route.js index b7fc756..f2ee090 100644 --- a/frontend/work-route.js +++ b/frontend/work-route.js @@ -20,6 +20,9 @@ if (queueFilters.includes(queue) && parts.length === 3) { return { kind: 'queue', filter: queue }; } + if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) { + return { kind: 'queue', filter: 'agenda', action: 'protect-today' }; + } if (parts[2] === 'update' && parts.length === 4) { const notificationId = positiveInteger(parts[3]); return notificationId ? { kind: 'update', notification_id: notificationId } : null; @@ -114,7 +117,7 @@ if (active && active !== fragment) onClose(); if (active === fragment) return; active = fragment; - onQueue(route.filter); + onQueue(route.filter, route.action || null); return; } const item = items.find(candidate => sameRoute(candidate, route)); diff --git a/src/push_notifications.py b/src/push_notifications.py index 3f4e376..f9306e5 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -352,6 +352,7 @@ async def _dispatch_deadline_reminders_unlocked( "title": f"{due_count} deadline{'s' if due_count != 1 else ''} need{'s' if due_count == 1 else ''} attention", "body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.", "route": "#/my-work/agenda", + "protect_route": "#/my-work/agenda/protect-today", "tag": f"stackchain-deadline-digest-{local_day}", "deadline_count": due_count, }, separators=(",", ":")) diff --git a/tests/test_deadline_reminders.py b/tests/test_deadline_reminders.py index 7a451fe..ab10680 100644 --- a/tests/test_deadline_reminders.py +++ b/tests/test_deadline_reminders.py @@ -47,6 +47,7 @@ async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplic "title": "1 deadline needs attention", "body": "Open Agenda to review or replan it.", "route": "#/my-work/agenda", + "protect_route": "#/my-work/agenda/protect-today", "tag": "stackchain-deadline-digest-2026-08-13", "deadline_count": 1, }] diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 06fe037..1f6b5da 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -40,6 +40,20 @@ COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js" PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js" WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js" UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js" + + +def test_protect_today_route_is_an_explicit_agenda_action(): + script = f""" +const routes = require({json.dumps(str(WORK_ROUTE))}); +process.stdout.write(JSON.stringify(routes.parse('#/my-work/agenda/protect-today'))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "kind": "queue", "filter": "agenda", "action": "protect-today" + } CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js" TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js" WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js" diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index c037435..6309b0c 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -459,14 +459,15 @@ 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_opens_agenda_without_rendering_private_copy(): +def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_private_copy(): result = run_worker_scenario( """ await dispatchPush({ title:'must-not-render', body:'private details must-not-render', - tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', deadline_count:3, + tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', + protect_route:'#/my-work/agenda/protect-today', deadline_count:3, }); - await dispatchNotificationClick('#/my-work/agenda'); + await dispatchNotificationClick('#/my-work/agenda/protect-today', 'protect-today', null, 'stackchain-deadline-digest-2026-08-13'); process.stdout.write(JSON.stringify(state)); """ ) @@ -476,15 +477,37 @@ def test_deadline_digest_push_opens_agenda_without_rendering_private_copy(): "options": { "body": "Open Agenda to review or replan them.", "tag": "stackchain-deadline-digest-2026-08-13", - "data": {"route": "#/my-work/agenda"}, + "actions": [ + {"action": "protect-today", "title": "Protect Today"}, + {"action": "open-agenda", "title": "Open Agenda"}, + ], + "data": { + "route": "#/my-work/agenda", + "protectRoute": "#/my-work/agenda/protect-today", + }, }, }] assert result["opened"] == [ - "https://forge.example/dashboard/#/my-work/agenda" + "https://forge.example/dashboard/#/my-work/agenda/protect-today" ] assert "must-not-render" not in json.dumps(result["notifications"]) +def test_deadline_digest_open_agenda_action_preserves_browsing_flow(): + 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'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["opened"] == ["https://forge.example/dashboard/#/my-work/agenda"] + + def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app(): result = run_worker_scenario( """