Review and resume snoozed mobile deadline reminders #1105
|
|
@ -440,6 +440,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.draft-filing-session[hidden] { display:none; }
|
||||
.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); }
|
||||
.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-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.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; }
|
||||
@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) { .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.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
|
|
|
|||
|
|
@ -7928,6 +7928,16 @@
|
|||
deadlineStatus:qs('#push-deadline-status'),
|
||||
deadlineHour:qs('#push-deadline-hour'),
|
||||
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,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
fetchJson:fetchReviewJson,
|
||||
|
|
|
|||
|
|
@ -260,6 +260,13 @@
|
|||
<button id="empty-work-create" type="button">Create an issue</button>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(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;
|
||||
|
||||
|
|
@ -16,6 +18,32 @@
|
|||
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() {
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
|
|
@ -136,6 +164,7 @@
|
|||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
|
|
@ -153,6 +182,7 @@
|
|||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
|
|
|
|||
15
src/main.py
15
src/main.py
|
|
@ -2148,7 +2148,7 @@ async def push_status(request: Request):
|
|||
device_id,
|
||||
)
|
||||
preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.deadline_preferences, device_id
|
||||
_push_subscription_store.deadline_preferences, device_id, now=time.time()
|
||||
)
|
||||
return {
|
||||
"available": configuration.enabled,
|
||||
|
|
@ -2158,6 +2158,7 @@ async def push_status(request: Request):
|
|||
"timezone": preferences["timezone"],
|
||||
"reminder_hour": preferences["reminder_hour"],
|
||||
"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}
|
||||
|
||||
|
||||
@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")
|
||||
async def record_session_activity(request: Request):
|
||||
session = request.state.dashboard_session
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -221,18 +222,21 @@ class PushSubscriptionStore:
|
|||
(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:
|
||||
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 = ?""",
|
||||
(session_id,),
|
||||
).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 {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
"reminder_days": row[3] if row else 2,
|
||||
"snoozed_until": snoozed_until,
|
||||
}
|
||||
|
||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa
|
|||
"timezone": "Europe/London",
|
||||
"reminder_hour": 8,
|
||||
"reminder_days": 2,
|
||||
"snoozed_until": None,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 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 registration = {pushManager:{
|
||||
getSubscription: async () => state.current || null,
|
||||
|
|
@ -29,9 +32,11 @@ const registration = {pushManager:{
|
|||
}};
|
||||
const feature = createPushNotifications({
|
||||
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'; }},
|
||||
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); });
|
||||
""".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"]
|
||||
|
||||
|
||||
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():
|
||||
root = MODULE.parents[1]
|
||||
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 "STACKCHAIN_VAPID_PUBLIC_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
|
||||
|
|
|
|||
|
|
@ -1055,6 +1055,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
|||
"timezone": "UTC",
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 2,
|
||||
"snoozed_until": None,
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": 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
|
||||
|
||||
|
||||
@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
|
||||
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user