diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 1cf8f6c..75ed01a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -6044,6 +6044,8 @@ const controller = createPushNotifications({ control:qs('#push-updates'), status:qs('#push-update-status'), + deadlineControl:qs('#push-deadlines'), + deadlineStatus:qs('#push-deadline-status'), notification:window.Notification, serviceWorker:navigator.serviceWorker, fetchJson:fetchReviewJson, diff --git a/frontend/index.html b/frontend/index.html index f9b0d1b..27cc15d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -129,6 +129,8 @@ + + diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js index 6226cfc..7625576 100644 --- a/frontend/push-notifications.js +++ b/frontend/push-notifications.js @@ -2,7 +2,7 @@ if (typeof module === 'object' && module.exports) module.exports = factory; else root.createPushNotifications = factory; })(typeof self !== 'undefined' ? self : this, function createPushNotifications({ - control, status, notification, serviceWorker, fetchJson, + control, status, deadlineControl, deadlineStatus, notification, serviceWorker, fetchJson, }) { let configuration = null; @@ -20,7 +20,9 @@ await fetchJson('api/v1/push-subscription', {method:'DELETE'}); await subscription?.unsubscribe?.(); control.checked = false; + if (deadlineControl) deadlineControl.checked = false; status.textContent = 'New update notifications are off for this device.'; + if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.'; } async function enable() { @@ -60,20 +62,53 @@ } } + async function changeDeadline() { + deadlineControl.disabled = true; + try { + const registration = await serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (deadlineControl.checked && !subscription) { + deadlineControl.checked = false; + deadlineStatus.textContent = 'Enable new update notifications first.'; + return; + } + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + await fetchJson('api/v1/push-subscription/deadlines', { + method:'PUT', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:9}), + }); + deadlineStatus.textContent = deadlineControl.checked + ? 'Deadline reminders enabled for 9:00 local time.' + : 'Deadline reminders are off for this device.'; + } catch (error) { + deadlineControl.checked = !deadlineControl.checked; + deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.'; + } finally { + deadlineControl.disabled = false; + } + } + async function init() { if (!control || !notification || !serviceWorker) return; control.addEventListener('change', change); + deadlineControl?.addEventListener('change', changeDeadline); configuration = await fetchJson('api/v1/push-subscription'); if (!configuration.available) { control.disabled = true; + if (deadlineControl) deadlineControl.disabled = true; status.textContent = 'New update notifications are not available on this server.'; return; } control.checked = Boolean(configuration.subscribed); + if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled); status.textContent = configuration.subscribed ? 'New update notifications enabled for this device.' : 'New update notifications are off for this device.'; + if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled + ? `Deadline reminders enabled for ${configuration.reminder_hour}:00 local time.` + : 'Deadline reminders are off for this device.'; } - return {init, change}; + return {init, change, changeDeadline}; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 59f43b3..4d814e9 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -316,6 +316,24 @@ self.addEventListener('push', event => { 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' + && /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag) + && Number.isSafeInteger(deadlineCount) + && deadlineCount > 0 + && deadlineCount <= 50 + ) { + event.waitUntil(self.registration.showNotification( + deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention', + { + body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'), + tag, + data: {route}, + } + )); + return; + } if ( route === '#/my-work/updates' && tag === 'stackchain-update-digest' diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 97a7d39..6055c97 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -403,6 +403,17 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict: } +async def assigned_issue_snapshot(*, limit: int = 50, max_pages: int = 20) -> dict: + """Load a complete, bounded assigned-issue snapshot for deadline dispatch.""" + items = [] + for page in range(1, max_pages + 1): + result = await work_page("issue", page, limit) + items.extend(result["items"]) + if not result["has_more"]: + return {"items": items, "complete": True} + return {"items": [], "complete": False} + + def _normalize_global_search_item(item: Any, kind: str) -> dict | None: if not isinstance(item, dict): return None diff --git a/src/main.py b/src/main.py index d628bf3..19a7605 100644 --- a/src/main.py +++ b/src/main.py @@ -48,7 +48,11 @@ from src.live_snapshot_store import ( ) from src.models import Issue, Milestone, PullRequest, Repo, User from src.passkey_store import PasskeyStore -from src.push_notifications import PushConfiguration, dispatch_unread_updates +from src.push_notifications import ( + PushConfiguration, + dispatch_deadline_reminders, + dispatch_unread_updates, +) from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint from src.push_subscription_store import PushSubscriptionStore from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit @@ -112,6 +116,13 @@ async def _push_poll_loop() -> None: max_concurrency=max_concurrency, max_individual_notifications=max_individual_notifications, ) + await dispatch_deadline_reminders( + _push_subscription_store, + _push_configuration(), + gitea_proxy.assigned_issue_snapshot, + session_active=dashboard_auth.managed_session_active, + send_timeout_seconds=send_timeout, + ) except asyncio.CancelledError: raise except Exception: @@ -346,6 +357,23 @@ class PushSubscriptionPayload(BaseModel): return value +class DeadlineReminderPayload(BaseModel): + enabled: bool + timezone: str = Field(min_length=1, max_length=64) + reminder_hour: int = Field(default=9, ge=0, le=23) + + @field_validator("timezone") + @classmethod + def validate_timezone(cls, value: str) -> str: + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + try: + ZoneInfo(value) + except ZoneInfoNotFoundError as error: + raise ValueError("Valid IANA timezone required") from error + return value + + StepUpAction = Literal[ "merge_pull", "submit_pull_review", @@ -1868,10 +1896,16 @@ async def push_status(request: Request): _push_subscription_store.is_subscribed, device_id, ) + preferences = await asyncio.to_thread( + _push_subscription_store.deadline_preferences, device_id + ) return { "available": configuration.enabled, "subscribed": subscribed, "public_key": configuration.public_key if configuration.enabled else "", + "deadline_enabled": preferences["enabled"], + "timezone": preferences["timezone"], + "reminder_hour": preferences["reminder_hour"], } @@ -1925,6 +1959,29 @@ async def unsubscribe_push(request: Request): return {"subscribed": False} +@app.put("/api/v1/push-subscription/deadlines") +async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request): + device_id = await dashboard_auth.session_management_id( + request.state.dashboard_session + ) + if payload.enabled and not await asyncio.to_thread( + _push_subscription_store.is_subscribed, device_id + ): + raise HTTPException(status_code=409, detail="Enable device notifications first") + await asyncio.to_thread( + _push_subscription_store.set_deadline_preferences, + device_id, + enabled=payload.enabled, + timezone=payload.timezone, + reminder_hour=payload.reminder_hour, + ) + return { + "deadline_enabled": payload.enabled, + "timezone": payload.timezone, + "reminder_hour": payload.reminder_hour, + } + + @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 0fde961..14fb463 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -3,7 +3,9 @@ import json import secrets import time from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from typing import Awaitable, Callable +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from src.push_subscription_store import PushSubscriptionStore from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint @@ -234,3 +236,97 @@ async def dispatch_unread_updates( return sum(counts) finally: await asyncio.to_thread(store.release_dispatch_lease, owner) + + +async def dispatch_deadline_reminders( + store: PushSubscriptionStore, + configuration: PushConfiguration, + assigned: Callable[[], Awaitable[dict]], + send: Callable[[dict, str], Awaitable[None]] | None = None, + **kwargs, +) -> int: + owner = secrets.token_urlsafe(18) + acquired = await asyncio.to_thread( + store.acquire_dispatch_lease, + owner, + now=time.time(), + lease_seconds=max(15.0, float(kwargs.get("send_timeout_seconds", 10.0)) + 5.0), + ) + if not acquired: + return 0 + try: + return await _dispatch_deadline_reminders_unlocked( + store, configuration, assigned, send, **kwargs + ) + finally: + await asyncio.to_thread(store.release_dispatch_lease, owner) + + +async def _dispatch_deadline_reminders_unlocked( + store: PushSubscriptionStore, + configuration: PushConfiguration, + assigned: Callable[[], Awaitable[dict]], + send: Callable[[dict, str], Awaitable[None]] | None = None, + *, + now: datetime | None = None, + session_active: Callable[[str], Awaitable[bool]] | None = None, + send_timeout_seconds: float = 10.0, +) -> int: + """Send one privacy-safe Agenda digest per eligible device and local day.""" + if not configuration.enabled: + return 0 + devices = await asyncio.to_thread(store.deadline_reminder_devices) + if not devices: + return 0 + snapshot = await assigned() + if snapshot.get("complete") is False: + return 0 + current = now or datetime.now(timezone.utc) + due_cutoff = current + timedelta(hours=48) + due_count = 0 + for item in snapshot.get("items", []): + if not isinstance(item, dict) or not item.get("due_date"): + continue + try: + due = datetime.fromisoformat(str(item["due_date"]).replace("Z", "+00:00")) + except ValueError: + continue + if due.tzinfo is None: + due = due.replace(tzinfo=timezone.utc) + if due <= due_cutoff: + due_count += 1 + if not due_count: + return 0 + delivered = 0 + for device in devices: + try: + local_now = current.astimezone(ZoneInfo(device.timezone)) + except ZoneInfoNotFoundError: + continue + local_day = local_now.date().isoformat() + if local_now.hour < device.reminder_hour or device.delivered_local_day == local_day: + continue + if session_active is not None and not await session_active(device.session_id): + await asyncio.to_thread(store.delete_session, device.session_id) + continue + payload = json.dumps({ + "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", + "tag": f"stackchain-deadline-digest-{local_day}", + "deadline_count": due_count, + }, separators=(",", ":")) + try: + operation = ( + send(device.subscription, payload) + if send is not None + else send_web_push(device.subscription, payload, configuration) + ) + await asyncio.wait_for(operation, timeout=send_timeout_seconds) + except Exception: + continue + await asyncio.to_thread( + store.mark_deadline_reminder_delivered, device.session_id, local_day + ) + delivered += 1 + return delivered diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py index 9e9139f..2784915 100644 --- a/src/push_subscription_store.py +++ b/src/push_subscription_store.py @@ -22,6 +22,15 @@ class PushDelivery: return tuple(thread_id for thread_id, _revision in self.digest_revisions) +@dataclass(frozen=True) +class DeadlineReminderDevice: + session_id: str + subscription: dict + timezone: str + reminder_hour: int + delivered_local_day: str | None + + def _revisions( values: Mapping[int, str] | Iterable[int | tuple[int, str]], ) -> tuple[tuple[int, str], ...]: @@ -78,6 +87,15 @@ class PushSubscriptionStore: FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS push_deadline_preferences ( + session_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + timezone TEXT NOT NULL DEFAULT 'UTC', + reminder_hour INTEGER NOT NULL DEFAULT 9, + delivered_local_day TEXT, + FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) + ON DELETE CASCADE + ); """ ) delivery_columns = { @@ -154,6 +172,56 @@ class PushSubscriptionStore: "SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,) ).fetchone() is not None + def set_deadline_preferences( + self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int + ) -> None: + with self._connect() as connection: + connection.execute( + """INSERT INTO push_deadline_preferences( + session_id, enabled, timezone, reminder_hour + ) VALUES (?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + enabled = excluded.enabled, + timezone = excluded.timezone, + reminder_hour = excluded.reminder_hour""", + (session_id, int(enabled), timezone, reminder_hour), + ) + + def deadline_preferences(self, session_id: str) -> dict: + with self._connect() as connection: + row = connection.execute( + """SELECT enabled, timezone, reminder_hour + FROM push_deadline_preferences WHERE session_id = ?""", + (session_id,), + ).fetchone() + return { + "enabled": bool(row[0]) if row else False, + "timezone": row[1] if row else "UTC", + "reminder_hour": row[2] if row else 9, + } + + def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]: + with self._connect() as connection: + rows = connection.execute( + """SELECT s.session_id, s.subscription_json, p.timezone, + p.reminder_hour, p.delivered_local_day + 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]) + for row in rows + ] + + 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 = ? + WHERE session_id = ? AND enabled = 1""", + (local_day, session_id), + ) + def claim_unseen( self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]] ) -> list[PushDelivery]: diff --git a/tests/test_deadline_reminders.py b/tests/test_deadline_reminders.py new file mode 100644 index 0000000..7695423 --- /dev/null +++ b/tests/test_deadline_reminders.py @@ -0,0 +1,162 @@ +import json +from datetime import datetime, timezone + +import pytest + +from src import gitea_proxy +from src.push_notifications import PushConfiguration, dispatch_deadline_reminders +from src.push_subscription_store import PushSubscriptionStore + + +@pytest.mark.anyio +async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplicates(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="America/New_York", reminder_hour=9 + ) + sent = [] + + async def assigned(): + return { + "complete": True, + "items": [ + { + "id": 42, + "title": "Private launch plan", + "repository": {"full_name": "private/repo"}, + "due_date": "2026-08-14T12:00:00Z", + }, + {"id": 43, "due_date": "2026-08-20T12:00:00Z"}, + ], + } + + async def send(_subscription, payload): + sent.append(json.loads(payload)) + + config = PushConfiguration("public", "private", "mailto:ops@example.com") + now = datetime(2026, 8, 13, 13, 5, tzinfo=timezone.utc) + + assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 1 + assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 0 + assert sent == [{ + "title": "1 deadline needs attention", + "body": "Open Agenda to review or replan it.", + "route": "#/my-work/agenda", + "tag": "stackchain-deadline-digest-2026-08-13", + "deadline_count": 1, + }] + assert "Private launch plan" not in json.dumps(sent) + assert "private/repo" not in json.dumps(sent) + + +@pytest.mark.anyio +async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(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="America/Los_Angeles", reminder_hour=9 + ) + sent = [] + + async def incomplete(): + return {"complete": False, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]} + + async def complete(): + return {"complete": True, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]} + + async def send(_subscription, payload): + sent.append(payload) + + config = PushConfiguration("public", "private", "mailto:ops@example.com") + assert await dispatch_deadline_reminders( + store, config, incomplete, send, + now=datetime(2026, 8, 13, 18, 0, tzinfo=timezone.utc), + ) == 0 + assert await dispatch_deadline_reminders( + store, config, complete, send, + now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc), + ) == 0 + assert sent == [] + + +def test_deadline_preferences_persist_on_the_existing_device_subscription(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="Europe/London", reminder_hour=8 + ) + + assert store.deadline_preferences("device-a") == { + "enabled": True, + "timezone": "Europe/London", + "reminder_hour": 8, + } + + +@pytest.mark.anyio +async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch): + pages = { + 1: {"items": [{"id": 1}], "has_more": True}, + 2: {"items": [{"id": 2}], "has_more": False}, + } + + async def work_page(stream, page, limit): + assert stream == "issue" + assert limit == 50 + return pages[page] + + monkeypatch.setattr(gitea_proxy, "work_page", work_page) + + assert await gitea_proxy.assigned_issue_snapshot() == { + "items": [{"id": 1}, {"id": 2}], + "complete": True, + } + + +@pytest.mark.anyio +async def test_competing_workers_send_one_deadline_digest(tmp_path): + path = tmp_path / "push.sqlite3" + first = PushSubscriptionStore(path) + second = PushSubscriptionStore(path) + first.upsert("device-a", { + "endpoint": "https://push.example/device-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + first.set_deadline_preferences( + "device-a", enabled=True, timezone="UTC", reminder_hour=9 + ) + sending = __import__("asyncio").Event() + release = __import__("asyncio").Event() + sent = [] + + async def assigned(): + return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]} + + async def send(_subscription, payload): + sent.append(payload) + sending.set() + await release.wait() + + config = PushConfiguration("public", "private", "mailto:ops@example.com") + now = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc) + active = __import__("asyncio").create_task( + dispatch_deadline_reminders(first, config, assigned, send, now=now) + ) + await sending.wait() + competing = await dispatch_deadline_reminders(second, config, assigned, send, now=now) + release.set() + + assert competing == 0 + assert await active == 1 + assert len(sent) == 1 diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py index f518a9d..a64c316 100644 --- a/tests/test_push_frontend.py +++ b/tests/test_push_frontend.py @@ -14,6 +14,11 @@ const control = { checked:false, disabled:false, addEventListener:(_name, callback) => state.change = callback, }; +const deadlineControl = { + checked:false, disabled:false, + addEventListener:(_name, callback) => state.deadlineChange = callback, +}; +const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }}; const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }}; const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }}; const registration = {pushManager:{ @@ -21,7 +26,7 @@ const registration = {pushManager:{ subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; }, }}; const feature = createPushNotifications({ - control, status, + control, status, deadlineControl, deadlineStatus, notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }}, serviceWorker: {ready:Promise.resolve(registration)}, fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; }, @@ -57,6 +62,26 @@ process.stdout.write(JSON.stringify(state)); assert result["text"] == "New update notifications enabled for this device." +def test_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt(): + result = run_scenario(""" +state.current = existing; +state.server = {available:true,subscribed:true,deadline_enabled:false,public_key:'AQID'}; +await feature.init(); +deadlineControl.checked = true; +await state.deadlineChange(); +process.stdout.write(JSON.stringify(state)); +""") + + assert result["prompts"] == 0 + assert result["subscriptions"] == [] + assert result["requests"][-1][0:2] == ["api/v1/push-subscription/deadlines", "PUT"] + body = json.loads(result["requests"][-1][2]) + assert body["enabled"] is True + assert body["reminder_hour"] == 9 + assert isinstance(body["timezone"], str) and body["timezone"] + assert result["deadlineText"] == "Deadline reminders enabled for 9:00 local time." + + def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller(): root = MODULE.parents[1] html = (root / "frontend" / "index.html").read_text() @@ -68,6 +93,8 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller(): assert 'id="push-updates"' in html assert 'id="push-update-status"' in html + assert 'id="push-deadlines"' in html + assert 'id="push-deadline-status"' in html assert '' in html assert "createPushNotifications({" in dashboard assert "BASE + 'static/push-notifications.js'" in worker diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py index 2bc4d95..dd2105d 100644 --- a/tests/test_push_notifications.py +++ b/tests/test_push_notifications.py @@ -819,7 +819,12 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe( ) assert await main.push_status(request) == { - "available": True, "subscribed": False, "public_key": "public-vapid" + "available": True, + "subscribed": False, + "public_key": "public-vapid", + "deadline_enabled": False, + "timezone": "UTC", + "reminder_hour": 9, } assert await main.subscribe_push(payload, request) == {"subscribed": True} assert (await main.push_status(request))["subscribed"] is True @@ -827,6 +832,32 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe( assert (await main.push_status(request))["subscribed"] is False +@pytest.mark.anyio +async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("session-a", { + "endpoint": "https://push.example/device-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + monkeypatch.setattr(main, "_push_subscription_store", store) + + 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())) + payload = main.DeadlineReminderPayload( + enabled=True, timezone="America/New_York", reminder_hour=9 + ) + + assert await main.update_deadline_reminders(payload, request) == { + "deadline_enabled": True, + "timezone": "America/New_York", + "reminder_hour": 9, + } + assert store.deadline_preferences("session-a")["enabled"] is True + + @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 e947291..18fba36 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -459,6 +459,32 @@ 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(): + 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, + }); + await dispatchNotificationClick('#/my-work/agenda'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["notifications"] == [{ + "title": "3 deadlines need attention", + "options": { + "body": "Open Agenda to review or replan them.", + "tag": "stackchain-deadline-digest-2026-08-13", + "data": {"route": "#/my-work/agenda"}, + }, + }] + assert result["opened"] == [ + "https://forge.example/dashboard/#/my-work/agenda" + ] + assert "must-not-render" not in json.dumps(result["notifications"]) + + def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app(): result = run_worker_scenario( """