feat: launch Protect Today from deadline reminders (Closes #723)
All checks were successful
CI / lint (pull_request) Successful in 1m22s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-13 08:57:32 +00:00
parent cbe4017bdc
commit 8a12524595
7 changed files with 89 additions and 8 deletions

View File

@ -6058,10 +6058,27 @@
if (!preserveRoute) workRoute.queue(filter); if (!preserveRoute) workRoute.queue(filter);
return true; return true;
} }
function openWorkQueueRoute(filter) { async function openWorkQueueRoute(filter, action = null) {
if (!selectWorkQueue(filter, { preserveRoute:true })) return; if (!selectWorkQueue(filter, { preserveRoute:true })) return;
qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus(); 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 => { qs('#work-milestone-filter').addEventListener('change', event => {
selectedWorkMilestone = event.target.value; selectedWorkMilestone = event.target.value;

View File

@ -314,12 +314,14 @@ self.addEventListener('push', event => {
try { payload = event.data?.json?.() || {}; } try { payload = event.data?.json?.() || {}; }
catch (_error) { return; } catch (_error) { return; }
const route = String(payload.route || ''); const route = String(payload.route || '');
const protectRoute = String(payload.protect_route || '');
const tag = String(payload.tag || ''); const tag = String(payload.tag || '');
const notificationId = Number(payload.notification_id); const notificationId = Number(payload.notification_id);
const updateCount = Number(payload.update_count); const updateCount = Number(payload.update_count);
const deadlineCount = Number(payload.deadline_count); const deadlineCount = Number(payload.deadline_count);
if ( if (
route === '#/my-work/agenda' route === '#/my-work/agenda'
&& protectRoute === '#/my-work/agenda/protect-today'
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag) && /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
&& Number.isSafeInteger(deadlineCount) && Number.isSafeInteger(deadlineCount)
&& deadlineCount > 0 && deadlineCount > 0
@ -330,7 +332,11 @@ self.addEventListener('push', event => {
{ {
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'), body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
tag, tag,
data: {route}, actions: [
{ action: 'protect-today', title: 'Protect Today' },
{ action: 'open-agenda', title: 'Open Agenda' },
],
data: {route, protectRoute},
} }
)); ));
return; return;
@ -380,6 +386,22 @@ async function openWorkRoute(route) {
self.addEventListener('notificationclick', event => { self.addEventListener('notificationclick', event => {
const route = String(event.notification.data?.route || ''); 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') { if (event.action === 'tomorrow') {
const notificationId = Number(event.notification.data?.notificationId); const notificationId = Number(event.notification.data?.notificationId);
if ( if (

View File

@ -20,6 +20,9 @@
if (queueFilters.includes(queue) && parts.length === 3) { if (queueFilters.includes(queue) && parts.length === 3) {
return { kind: 'queue', filter: queue }; 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) { if (parts[2] === 'update' && parts.length === 4) {
const notificationId = positiveInteger(parts[3]); const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null; return notificationId ? { kind: 'update', notification_id: notificationId } : null;
@ -114,7 +117,7 @@
if (active && active !== fragment) onClose(); if (active && active !== fragment) onClose();
if (active === fragment) return; if (active === fragment) return;
active = fragment; active = fragment;
onQueue(route.filter); onQueue(route.filter, route.action || null);
return; return;
} }
const item = items.find(candidate => sameRoute(candidate, route)); const item = items.find(candidate => sameRoute(candidate, route));

View File

@ -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", "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'}.", "body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.",
"route": "#/my-work/agenda", "route": "#/my-work/agenda",
"protect_route": "#/my-work/agenda/protect-today",
"tag": f"stackchain-deadline-digest-{local_day}", "tag": f"stackchain-deadline-digest-{local_day}",
"deadline_count": due_count, "deadline_count": due_count,
}, separators=(",", ":")) }, separators=(",", ":"))

View File

@ -47,6 +47,7 @@ async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplic
"title": "1 deadline needs attention", "title": "1 deadline needs attention",
"body": "Open Agenda to review or replan it.", "body": "Open Agenda to review or replan it.",
"route": "#/my-work/agenda", "route": "#/my-work/agenda",
"protect_route": "#/my-work/agenda/protect-today",
"tag": "stackchain-deadline-digest-2026-08-13", "tag": "stackchain-deadline-digest-2026-08-13",
"deadline_count": 1, "deadline_count": 1,
}] }]

View File

@ -40,6 +40,20 @@ COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js" PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js" WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.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" CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js" TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js" WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"

View File

@ -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"]) 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( result = run_worker_scenario(
""" """
await dispatchPush({ await dispatchPush({
title:'must-not-render', body:'private details must-not-render', 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)); process.stdout.write(JSON.stringify(state));
""" """
) )
@ -476,15 +477,37 @@ def test_deadline_digest_push_opens_agenda_without_rendering_private_copy():
"options": { "options": {
"body": "Open Agenda to review or replan them.", "body": "Open Agenda to review or replan them.",
"tag": "stackchain-deadline-digest-2026-08-13", "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"] == [ 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"]) 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(): def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
result = run_worker_scenario( result = run_worker_scenario(
""" """