Snooze mobile deadline reminders from the lock screen #1103
|
|
@ -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') {
|
||||
|
|
|
|||
17
src/main.py
17
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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user