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