Merge pull request 'Snooze mobile deadline reminders from the lock screen' (#1103) from timmy/1102-deadline-reminder-snooze into main
All checks were successful
CI / lint (push) Successful in 2m39s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 2m45s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
rockachopa 2026-08-19 00:08:03 +00:00
commit 625d937f64
17 changed files with 333 additions and 44 deletions

View File

@ -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') {

View File

@ -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

View File

@ -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(

View File

@ -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),
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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():

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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