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({
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,

View File

@ -129,6 +129,8 @@
<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>
<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="retry-offline-storage" type="button" hidden>Retry offline saving</button>
<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;
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};
});

View File

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

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:
if not isinstance(item, dict):
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.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

View File

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

View File

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

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,
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 '<script src="static/push-notifications.js"></script>' in html
assert "createPushNotifications({" in dashboard
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) == {
"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")

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