Merge pull request 'Review and resume snoozed mobile deadline reminders' (#1105) from timmy/1104-review-snoozed-deadlines into main
All checks were successful
CI / lint (push) Successful in 2m49s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m50s
CI / release-candidate (push) Successful in 7s

Review and resume snoozed mobile deadline reminders (#1105)
This commit is contained in:
rockachopa 2026-08-19 01:09:49 +00:00
commit 8ad02933c9
9 changed files with 198 additions and 5 deletions

View File

@ -440,6 +440,9 @@ textarea { resize: vertical; min-height: 120px; }
.draft-filing-session[hidden] { display:none; } .draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } .draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); } .my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.deadline-snooze { box-sizing:border-box; min-width:0; margin:10px 0; padding:12px; border:1px solid #a16207; border-radius:12px; background:#2a1d08; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.deadline-snooze p { margin:4px 0 0; overflow-wrap:anywhere; }
.deadline-snooze button { min-height:44px; flex:0 0 auto; }
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; } .agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; } .agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.agenda-replan-launch p { margin:4px 0 0; } .agenda-replan-launch p { margin:4px 0 0; }
@ -466,6 +469,7 @@ textarea { resize: vertical; min-height: 120px; }
.protect-today button { min-height:44px; flex:0 0 auto; } .protect-today button { min-height:44px; flex:0 0 auto; }
@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } } @media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } }
@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } } @media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } }
@media(max-width:430px) { .deadline-snooze { align-items:stretch; flex-direction:column; } .deadline-snooze button { width:100%; } }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; } .my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; } .my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); } .my-work-card:hover { border-color:var(--accent); }

View File

@ -7928,6 +7928,16 @@
deadlineStatus:qs('#push-deadline-status'), deadlineStatus:qs('#push-deadline-status'),
deadlineHour:qs('#push-deadline-hour'), deadlineHour:qs('#push-deadline-hour'),
deadlineDays:qs('#push-deadline-days'), deadlineDays:qs('#push-deadline-days'),
deadlineSnooze:qs('#deadline-snooze'),
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
onReviewDeadlines:() => {
window.location.hash = '#/my-work/agenda';
setTimeout(() => {
const protect = qs('#protect-today');
if (!protect.closest('[hidden]')) protect.click();
}, 0);
},
notification:window.Notification, notification:window.Notification,
serviceWorker:navigator.serviceWorker, serviceWorker:navigator.serviceWorker,
fetchJson:fetchReviewJson, fetchJson:fetchReviewJson,

View File

@ -260,6 +260,13 @@
<button id="empty-work-create" type="button">Create an issue</button> <button id="empty-work-create" type="button">Create an issue</button>
</div> </div>
</section> </section>
<section class="deadline-snooze" id="deadline-snooze" aria-labelledby="deadline-snooze-heading" hidden>
<div>
<strong id="deadline-snooze-heading">Deadline reminders paused</strong>
<p class="small" id="deadline-snooze-status" role="status" aria-live="polite"></p>
</div>
<button id="review-snoozed-deadlines" type="button">Review now</button>
</section>
<section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden> <section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden>
<div class="agenda-replan-launch"> <div class="agenda-replan-launch">
<div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div> <div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div>

View File

@ -2,7 +2,9 @@
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, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson, control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson,
}) { }) {
let configuration = null; let configuration = null;
@ -16,6 +18,32 @@
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`; return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
} }
function renderDeadlineSnooze() {
if (!deadlineSnooze) return;
const wakeAt = Number(configuration?.snoozed_until || 0);
deadlineSnooze.hidden = !wakeAt;
if (wakeAt && deadlineSnoozeStatus) {
const localTime = new Intl.DateTimeFormat(undefined, {
hour:'numeric', minute:'2-digit',
}).format(new Date(wakeAt * 1000));
deadlineSnoozeStatus.textContent = `Deadline reminders snoozed until ${localTime}.`;
}
}
async function reviewSnoozedDeadlines() {
deadlineSnoozeReview.disabled = true;
try {
await fetchJson('api/v1/push-subscription/deadlines/snooze', {method:'DELETE'});
configuration.snoozed_until = null;
renderDeadlineSnooze();
onReviewDeadlines?.();
} catch (error) {
deadlineSnoozeStatus.textContent = 'Could not resume deadline reminders. Check your connection and try again.';
} finally {
deadlineSnoozeReview.disabled = false;
}
}
function deadlineReadiness() { function deadlineReadiness() {
if (!configuration?.available || deadlineControl?.disabled) { if (!configuration?.available || deadlineControl?.disabled) {
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'}; return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
@ -136,6 +164,7 @@
if (!control || !notification || !serviceWorker) return; if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change); control.addEventListener('change', change);
deadlineControl?.addEventListener('change', changeDeadline); deadlineControl?.addEventListener('change', changeDeadline);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
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;
@ -153,6 +182,7 @@
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days) ? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
: 'Deadline reminders are off for this device.'; : 'Deadline reminders are off for this device.';
renderDeadlineSnooze();
} }
return {init, change, changeDeadline, enableDeadline, deadlineReadiness}; return {init, change, changeDeadline, enableDeadline, deadlineReadiness};

View File

@ -2148,7 +2148,7 @@ async def push_status(request: Request):
device_id, device_id,
) )
preferences = await asyncio.to_thread( preferences = await asyncio.to_thread(
_push_subscription_store.deadline_preferences, device_id _push_subscription_store.deadline_preferences, device_id, now=time.time()
) )
return { return {
"available": configuration.enabled, "available": configuration.enabled,
@ -2158,6 +2158,7 @@ async def push_status(request: Request):
"timezone": preferences["timezone"], "timezone": preferences["timezone"],
"reminder_hour": preferences["reminder_hour"], "reminder_hour": preferences["reminder_hour"],
"reminder_days": preferences["reminder_days"], "reminder_days": preferences["reminder_days"],
"snoozed_until": preferences["snoozed_until"],
} }
@ -2253,6 +2254,18 @@ async def snooze_deadline_reminder(request: Request):
return {"snoozed": True, "snoozed_until": now + 3_600} return {"snoozed": True, "snoozed_until": now + 3_600}
@app.delete("/api/v1/push-subscription/deadlines/snooze")
async def resume_deadline_reminders(request: Request):
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
await asyncio.to_thread(
_push_subscription_store.clear_deadline_snooze,
device_id,
)
return {"snoozed": False}
@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

@ -1,5 +1,6 @@
import json import json
import sqlite3 import sqlite3
import time
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -221,18 +222,21 @@ class PushSubscriptionStore:
(session_id, int(enabled), timezone, reminder_hour, reminder_days), (session_id, int(enabled), timezone, reminder_hour, reminder_days),
) )
def deadline_preferences(self, session_id: str) -> dict: def deadline_preferences(self, session_id: str, *, now: float | None = None) -> dict:
with self._connect() as connection: with self._connect() as connection:
row = connection.execute( row = connection.execute(
"""SELECT enabled, timezone, reminder_hour, reminder_days """SELECT enabled, timezone, reminder_hour, reminder_days, snoozed_until
FROM push_deadline_preferences WHERE session_id = ?""", FROM push_deadline_preferences WHERE session_id = ?""",
(session_id,), (session_id,),
).fetchone() ).fetchone()
current_time = time.time() if now is None else now
snoozed_until = row[4] if row and row[4] and row[4] > current_time else None
return { return {
"enabled": bool(row[0]) if row else False, "enabled": bool(row[0]) if row else False,
"timezone": row[1] if row else "UTC", "timezone": row[1] if row else "UTC",
"reminder_hour": row[2] if row else 9, "reminder_hour": row[2] if row else 9,
"reminder_days": row[3] if row else 2, "reminder_days": row[3] if row else 2,
"snoozed_until": snoozed_until,
} }
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]: def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:

View File

@ -341,6 +341,7 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa
"timezone": "Europe/London", "timezone": "Europe/London",
"reminder_hour": 8, "reminder_hour": 8,
"reminder_days": 2, "reminder_days": 2,
"snoozed_until": None,
} }

View File

@ -22,6 +22,9 @@ const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callba
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback}; const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }}; 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 deadlineSnooze = {hidden:true};
const deadlineSnoozeStatus = {set textContent(value) { state.snoozeText = value; }, get textContent() { return state.snoozeText; }};
const deadlineSnoozeReview = {disabled:false, addEventListener:(_name, callback) => state.reviewSnooze = callback};
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:{
getSubscription: async () => state.current || null, getSubscription: async () => state.current || null,
@ -29,9 +32,11 @@ const registration = {pushManager:{
}}; }};
const feature = createPushNotifications({ const feature = createPushNotifications({
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; },
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 || '']); if (state.failResume && options.method === 'DELETE' && url.endsWith('/deadlines/snooze')) throw new Error('offline'); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
}); });
(async () => { __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); }); (async () => { __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script) """.replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
@ -158,6 +163,52 @@ process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"] assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
def test_active_deadline_snooze_is_visible_in_agenda_with_a_local_resume_time():
result = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,snoozed_until:1765003600,public_key:'AQID'};
state.current = existing;
await feature.init();
process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,text:state.snoozeText}));
""")
assert result["hidden"] is False
assert result["text"].startswith("Deadline reminders snoozed until ")
def test_review_now_resumes_this_device_and_opens_the_deadline_review_flow():
result = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,snoozed_until:1765003600,public_key:'AQID'};
state.current = existing;
await feature.init();
await state.reviewSnooze();
process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,disabled:deadlineSnoozeReview.disabled,reviewed:state.reviewed,requests:state.requests}));
""")
assert result["requests"][-1][0:2] == [
"api/v1/push-subscription/deadlines/snooze", "DELETE",
]
assert result["hidden"] is True
assert result["disabled"] is False
assert result["reviewed"] is True
def test_failed_resume_keeps_the_snooze_visible_and_retryable():
result = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,snoozed_until:1765003600,public_key:'AQID'};
state.current = existing;
await feature.init();
state.failResume = true;
await state.reviewSnooze();
process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,disabled:deadlineSnoozeReview.disabled,text:state.snoozeText,reviewed:state.reviewed}));
""")
assert result == {
"hidden": False,
"disabled": False,
"text": "Could not resume deadline reminders. Check your connection and try again.",
}
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()
@ -181,3 +232,21 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
assert "pywebpush==" in requirements assert "pywebpush==" in requirements
assert "STACKCHAIN_VAPID_PUBLIC_KEY" in readme assert "STACKCHAIN_VAPID_PUBLIC_KEY" in readme
assert "STACKCHAIN_VAPID_PRIVATE_KEY" in readme assert "STACKCHAIN_VAPID_PRIVATE_KEY" in readme
def test_mobile_agenda_mounts_a_touch_safe_snooze_recovery_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
assert 'id="deadline-snooze"' in html
assert 'id="deadline-snooze-status"' in html
assert 'id="review-snoozed-deadlines"' in html
assert "deadlineSnooze:qs('#deadline-snooze')" in dashboard
assert "onReviewDeadlines:" in dashboard
assert "window.location.hash = '#/my-work/agenda'" in dashboard
assert "qs('#protect-today')" in dashboard
assert ".deadline-snooze" in css
assert ".deadline-snooze button { min-height:44px;" in css
assert "overflow-wrap:anywhere" in css

View File

@ -1055,6 +1055,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"timezone": "UTC", "timezone": "UTC",
"reminder_hour": 9, "reminder_hour": 9,
"reminder_days": 2, "reminder_days": 2,
"snoozed_until": None,
} }
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
@ -1119,6 +1120,60 @@ async def test_authenticated_device_can_snooze_its_enabled_deadline_reminder(tmp
assert devices["session-b"].snoozed_until is None assert devices["session-b"].snoozed_until is None
@pytest.mark.anyio
async def test_push_status_exposes_only_the_current_devices_active_deadline_snooze(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("session-a", "session-b"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
session_id, enabled=True, timezone="UTC", reminder_hour=9
)
store.snooze_deadline_reminder("session-a", now=1_765_000_000)
store.snooze_deadline_reminder("session-b", now=1_765_001_000)
monkeypatch.setattr(main, "_push_subscription_store", store)
monkeypatch.setattr(main.time, "time", lambda: 1_765_000_100)
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()))
status = await main.push_status(request)
assert status["snoozed_until"] == 1_765_003_600
assert store.deadline_preferences("session-a", now=1_765_004_000)["snoozed_until"] is None
@pytest.mark.anyio
async def test_authenticated_device_can_resume_only_its_own_deadline_reminders(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("session-a", "session-b"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
session_id, enabled=True, timezone="UTC", reminder_hour=9
)
store.snooze_deadline_reminder(session_id, now=1_765_000_000)
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()))
assert await main.resume_deadline_reminders(request) == {"snoozed": False}
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
assert devices["session-a"].snoozed_until is None
assert devices["session-b"].snoozed_until == 1_765_003_600
@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")