Merge pull request 'Send opt-in mobile deadline reminders into Agenda' (#710) from timmy/709-mobile-deadline-reminders into main
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s

This commit is contained in:
timmy 2026-08-13 05:11:29 +00:00
commit c503994be1
12 changed files with 540 additions and 5 deletions

View File

@ -6044,6 +6044,8 @@
const controller = createPushNotifications({ const controller = createPushNotifications({
control:qs('#push-updates'), control:qs('#push-updates'),
status:qs('#push-update-status'), status:qs('#push-update-status'),
deadlineControl:qs('#push-deadlines'),
deadlineStatus:qs('#push-deadline-status'),
notification:window.Notification, notification:window.Notification,
serviceWorker:navigator.serviceWorker, serviceWorker:navigator.serviceWorker,
fetchJson:fetchReviewJson, fetchJson:fetchReviewJson,

View File

@ -129,6 +129,8 @@
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label> <label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label> <label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<span class="small" id="push-update-status" role="status" aria-live="polite"></span> <span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
<button id="clear-offline-work" type="button">Clear offline work data</button> <button id="clear-offline-work" type="button">Clear offline work data</button>
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button> <button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span> <span class="small" id="offline-work-status" role="status" aria-live="polite"></span>

View File

@ -2,7 +2,7 @@
if (typeof module === 'object' && module.exports) module.exports = factory; if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createPushNotifications = factory; else root.createPushNotifications = factory;
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({ })(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, notification, serviceWorker, fetchJson, control, status, deadlineControl, deadlineStatus, notification, serviceWorker, fetchJson,
}) { }) {
let configuration = null; let configuration = null;
@ -20,7 +20,9 @@
await fetchJson('api/v1/push-subscription', {method:'DELETE'}); await fetchJson('api/v1/push-subscription', {method:'DELETE'});
await subscription?.unsubscribe?.(); await subscription?.unsubscribe?.();
control.checked = false; control.checked = false;
if (deadlineControl) deadlineControl.checked = false;
status.textContent = 'New update notifications are off for this device.'; status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
} }
async function enable() { 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() { async function init() {
if (!control || !notification || !serviceWorker) return; if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change); control.addEventListener('change', change);
deadlineControl?.addEventListener('change', changeDeadline);
configuration = await fetchJson('api/v1/push-subscription'); configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) { if (!configuration.available) {
control.disabled = true; control.disabled = true;
if (deadlineControl) deadlineControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.'; status.textContent = 'New update notifications are not available on this server.';
return; return;
} }
control.checked = Boolean(configuration.subscribed); control.checked = Boolean(configuration.subscribed);
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
status.textContent = configuration.subscribed status.textContent = configuration.subscribed
? 'New update notifications enabled for this device.' ? 'New update notifications enabled for this device.'
: 'New update notifications are off 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};
}); });

View File

@ -316,6 +316,24 @@ self.addEventListener('push', event => {
const tag = String(payload.tag || ''); const tag = String(payload.tag || '');
const notificationId = Number(payload.notification_id); const notificationId = Number(payload.notification_id);
const updateCount = Number(payload.update_count); const updateCount = Number(payload.update_count);
const deadlineCount = Number(payload.deadline_count);
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 ( if (
route === '#/my-work/updates' route === '#/my-work/updates'
&& tag === 'stackchain-update-digest' && tag === 'stackchain-update-digest'

View File

@ -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: def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
if not isinstance(item, dict): if not isinstance(item, dict):
return None return None

View File

@ -48,7 +48,11 @@ from src.live_snapshot_store import (
) )
from src.models import Issue, Milestone, PullRequest, Repo, User from src.models import Issue, Milestone, PullRequest, Repo, User
from src.passkey_store import PasskeyStore 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_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
from src.push_subscription_store import PushSubscriptionStore from src.push_subscription_store import PushSubscriptionStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
@ -112,6 +116,13 @@ async def _push_poll_loop() -> None:
max_concurrency=max_concurrency, max_concurrency=max_concurrency,
max_individual_notifications=max_individual_notifications, 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: except asyncio.CancelledError:
raise raise
except Exception: except Exception:
@ -346,6 +357,23 @@ class PushSubscriptionPayload(BaseModel):
return value 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[ StepUpAction = Literal[
"merge_pull", "merge_pull",
"submit_pull_review", "submit_pull_review",
@ -1868,10 +1896,16 @@ async def push_status(request: Request):
_push_subscription_store.is_subscribed, _push_subscription_store.is_subscribed,
device_id, device_id,
) )
preferences = await asyncio.to_thread(
_push_subscription_store.deadline_preferences, device_id
)
return { return {
"available": configuration.enabled, "available": configuration.enabled,
"subscribed": subscribed, "subscribed": subscribed,
"public_key": configuration.public_key if configuration.enabled else "", "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} 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") @app.post("/api/v1/session/activity")
async def record_session_activity(request: Request): async def record_session_activity(request: Request):
session = request.state.dashboard_session session = request.state.dashboard_session

View File

@ -3,7 +3,9 @@ import json
import secrets import secrets
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable from typing import Awaitable, Callable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from src.push_subscription_store import PushSubscriptionStore from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
@ -234,3 +236,97 @@ async def dispatch_unread_updates(
return sum(counts) return sum(counts)
finally: finally:
await asyncio.to_thread(store.release_dispatch_lease, owner) 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

View File

@ -22,6 +22,15 @@ class PushDelivery:
return tuple(thread_id for thread_id, _revision in self.digest_revisions) 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( def _revisions(
values: Mapping[int, str] | Iterable[int | tuple[int, str]], values: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> tuple[tuple[int, str], ...]: ) -> tuple[tuple[int, str], ...]:
@ -78,6 +87,15 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE 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 = { delivery_columns = {
@ -154,6 +172,56 @@ class PushSubscriptionStore:
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,) "SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
).fetchone() is not None ).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( def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]] self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]: ) -> list[PushDelivery]:

View File

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

View File

@ -14,6 +14,11 @@ const control = {
checked:false, disabled:false, checked:false, disabled:false,
addEventListener:(_name, callback) => state.change = callback, 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 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 existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
const registration = {pushManager:{ const registration = {pushManager:{
@ -21,7 +26,7 @@ const registration = {pushManager:{
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; }, subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
}}; }};
const feature = createPushNotifications({ const feature = createPushNotifications({
control, status, control, status, deadlineControl, deadlineStatus,
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }}, notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
serviceWorker: {ready:Promise.resolve(registration)}, 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'}; }, 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." 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(): def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
root = MODULE.parents[1] root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text() 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-updates"' in html
assert 'id="push-update-status"' in html assert 'id="push-update-status"' in html
assert 'id="push-deadlines"' in html
assert 'id="push-deadline-status"' in html
assert '<script src="static/push-notifications.js"></script>' in html assert '<script src="static/push-notifications.js"></script>' in html
assert "createPushNotifications({" in dashboard assert "createPushNotifications({" in dashboard
assert "BASE + 'static/push-notifications.js'" in worker assert "BASE + 'static/push-notifications.js'" in worker

View File

@ -819,7 +819,12 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
) )
assert await main.push_status(request) == { 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.subscribe_push(payload, request) == {"subscribed": True}
assert (await main.push_status(request))["subscribed"] is 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 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 @pytest.mark.anyio
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch): async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3") store = PushSubscriptionStore(tmp_path / "push.sqlite3")

View File

@ -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"]) 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(): def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
result = run_worker_scenario( result = run_worker_scenario(
""" """