diff --git a/frontend/dashboard.css b/frontend/dashboard.css index dcc1905..52140ef 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -40,7 +40,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid # .device-setup-step p { margin:5px 0 0; } .device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; } .device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; } -.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour { min-height:44px; } +.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; } .device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; } .device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; } .device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 97465c6..493909f 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -6104,6 +6104,13 @@ qs('#device-setup-deadline-hour').addEventListener('change', event => { qs('#push-deadline-hour').value = event.target.value; }); + qs('#push-deadline-days').addEventListener('change', event => { + qs('#device-setup-deadline-days').value = event.target.value; + if (qs('#push-deadlines').checked) pushController?.changeDeadline(); + }); + qs('#device-setup-deadline-days').addEventListener('change', event => { + qs('#push-deadline-days').value = event.target.value; + }); let pushControllerReady = Promise.resolve(null); if ('serviceWorker' in navigator) { pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => { @@ -6114,6 +6121,7 @@ deadlineControl:qs('#push-deadlines'), deadlineStatus:qs('#push-deadline-status'), deadlineHour:qs('#push-deadline-hour'), + deadlineDays:qs('#push-deadline-days'), notification:window.Notification, serviceWorker:navigator.serviceWorker, fetchJson:fetchReviewJson, @@ -6121,6 +6129,7 @@ await controller.init(); pushController = controller; qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value; + qs('#device-setup-deadline-days').value = qs('#push-deadline-days').value; return controller; }).catch(error => { qs('#push-updates').disabled = true; @@ -6169,6 +6178,7 @@ const controller = await pushControllerReady; if (!controller) return; qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value; + qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value; await controller.enableDeadline(); }, }); diff --git a/frontend/index.html b/frontend/index.html index d753cf7..e663395 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -87,6 +87,8 @@
+ +
@@ -139,6 +141,7 @@ + diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js index a89f1be..745a785 100644 --- a/frontend/push-notifications.js +++ b/frontend/push-notifications.js @@ -2,7 +2,7 @@ if (typeof module === 'object' && module.exports) module.exports = factory; else root.createPushNotifications = factory; })(typeof self !== 'undefined' ? self : this, function createPushNotifications({ - control, status, deadlineControl, deadlineStatus, deadlineHour, notification, serviceWorker, fetchJson, + control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson, }) { let configuration = null; @@ -10,12 +10,18 @@ return `${String(Number(value)).padStart(2, '0')}:00`; } + function enabledDeadlineText(hour, days) { + const value = Number(days ?? 2); + const horizon = value === 0 ? 'due today' : `next ${value} days`; + return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`; + } + function deadlineReadiness() { if (!configuration?.available || deadlineControl?.disabled) { return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'}; } return configuration.deadline_enabled - ? {state:'complete', detail:`Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`} + ? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)} : {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'}; } @@ -84,6 +90,7 @@ async function changeDeadline() { deadlineControl.disabled = true; if (deadlineHour) deadlineHour.disabled = true; + if (deadlineDays) deadlineDays.disabled = true; try { const registration = await serviceWorker.ready; let subscription = await registration.pushManager.getSubscription(); @@ -95,16 +102,18 @@ } const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9); + const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2); await fetchJson('api/v1/push-subscription/deadlines', { method:'PUT', headers:{'Content-Type':'application/json'}, - body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour}), + body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}), }); configuration.deadline_enabled = deadlineControl.checked; configuration.reminder_hour = reminderHour; + configuration.reminder_days = reminderDays; configuration.timezone = timezone; deadlineStatus.textContent = deadlineControl.checked - ? `Deadline reminders enabled for ${formattedHour(reminderHour)} local time.` + ? enabledDeadlineText(reminderHour, reminderDays) : 'Deadline reminders are off for this device.'; return true; } catch (error) { @@ -114,6 +123,7 @@ } finally { deadlineControl.disabled = false; if (deadlineHour) deadlineHour.disabled = false; + if (deadlineDays) deadlineDays.disabled = false; } } @@ -136,11 +146,12 @@ control.checked = Boolean(configuration.subscribed); if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled); if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9); + if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2); 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 ${formattedHour(configuration.reminder_hour)} local time.` + ? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days) : 'Deadline reminders are off for this device.'; } diff --git a/src/main.py b/src/main.py index 687689f..809d17a 100644 --- a/src/main.py +++ b/src/main.py @@ -394,6 +394,7 @@ class DeadlineReminderPayload(BaseModel): enabled: bool timezone: str = Field(min_length=1, max_length=64) reminder_hour: int = Field(default=9, ge=0, le=23) + reminder_days: Literal[0, 2, 7] = 2 @field_validator("timezone") @classmethod @@ -1939,6 +1940,7 @@ async def push_status(request: Request): "deadline_enabled": preferences["enabled"], "timezone": preferences["timezone"], "reminder_hour": preferences["reminder_hour"], + "reminder_days": preferences["reminder_days"], } @@ -2007,11 +2009,13 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R enabled=payload.enabled, timezone=payload.timezone, reminder_hour=payload.reminder_hour, + reminder_days=payload.reminder_days, ) return { "deadline_enabled": payload.enabled, "timezone": payload.timezone, "reminder_hour": payload.reminder_hour, + "reminder_days": payload.reminder_days, } diff --git a/src/push_notifications.py b/src/push_notifications.py index cf68741..1dcb7f4 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -323,7 +323,7 @@ async def _dispatch_deadline_reminders_unlocked( due_counts = {} for device in eligible_devices: local_now = current.astimezone(ZoneInfo(device.timezone)) - local_cutoff = local_now.date() + timedelta(days=2) + local_cutoff = local_now.date() + timedelta(days=device.reminder_days) due_count = sum(due_day <= local_cutoff for due_day in due_days) if due_count: due_counts[device.session_id] = due_count diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py index 7ce5098..13337b8 100644 --- a/src/push_subscription_store.py +++ b/src/push_subscription_store.py @@ -28,6 +28,7 @@ class DeadlineReminderDevice: subscription: dict timezone: str reminder_hour: int + reminder_days: int delivered_local_day: str | None @@ -92,6 +93,7 @@ class PushSubscriptionStore: enabled INTEGER NOT NULL DEFAULT 0, timezone TEXT NOT NULL DEFAULT 'UTC', reminder_hour INTEGER NOT NULL DEFAULT 9, + reminder_days INTEGER NOT NULL DEFAULT 2, delivered_local_day TEXT, FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) ON DELETE CASCADE @@ -131,6 +133,14 @@ class PushSubscriptionStore: DROP TABLE push_dispatch_lease_legacy; """ ) + preference_columns = { + row[1] + for row in connection.execute("PRAGMA table_info(push_deadline_preferences)") + } + if "reminder_days" not in preference_columns: + connection.execute( + "ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2" + ) os.chmod(self.path, 0o600) def _connect(self): @@ -192,24 +202,26 @@ class PushSubscriptionStore: ).fetchone() is not None def set_deadline_preferences( - self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int + self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int, + reminder_days: int = 2, ) -> None: with self._connect() as connection: connection.execute( """INSERT INTO push_deadline_preferences( - session_id, enabled, timezone, reminder_hour - ) VALUES (?, ?, ?, ?) + session_id, enabled, timezone, reminder_hour, reminder_days + ) 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), + reminder_hour = excluded.reminder_hour, + reminder_days = excluded.reminder_days""", + (session_id, int(enabled), timezone, reminder_hour, reminder_days), ) def deadline_preferences(self, session_id: str) -> dict: with self._connect() as connection: row = connection.execute( - """SELECT enabled, timezone, reminder_hour + """SELECT enabled, timezone, reminder_hour, reminder_days FROM push_deadline_preferences WHERE session_id = ?""", (session_id,), ).fetchone() @@ -217,19 +229,20 @@ class PushSubscriptionStore: "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, } 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 + p.reminder_hour, p.reminder_days, 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]) + DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5]) for row in rows ] diff --git a/tests/test_deadline_reminders.py b/tests/test_deadline_reminders.py index ae6905c..84ae798 100644 --- a/tests/test_deadline_reminders.py +++ b/tests/test_deadline_reminders.py @@ -85,6 +85,48 @@ async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_pa assert sent == {"tokyo": 2, "la": 1} +@pytest.mark.anyio +async def test_deadline_reminder_uses_each_devices_confirmed_horizon(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + for device, reminder_days in (("today", 0), ("week", 7)): + store.upsert(device, { + "endpoint": f"https://push.example/{device}", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_deadline_preferences( + device, + enabled=True, + timezone="UTC", + reminder_hour=9, + reminder_days=reminder_days, + ) + + async def assigned(): + return {"complete": True, "items": [ + {"id": 1, "due_date": "2026-08-13"}, + {"id": 2, "due_date": "2026-08-15"}, + {"id": 3, "due_date": "2026-08-20"}, + {"id": 4, "due_date": "2026-08-21"}, + ]} + + sent = {} + + async def send(subscription, payload): + device = subscription["endpoint"].rsplit("/", 1)[-1] + sent[device] = json.loads(payload)["deadline_count"] + + delivered = await dispatch_deadline_reminders( + store, + PushConfiguration("public", "private", "mailto:ops@example.com"), + assigned, + send, + now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc), + ) + + assert delivered == 2 + assert sent == {"today": 1, "week": 3} + + @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") @@ -194,9 +236,37 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa "enabled": True, "timezone": "Europe/London", "reminder_hour": 8, + "reminder_days": 2, } +def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path): + database = tmp_path / "push.sqlite3" + import sqlite3 + + with sqlite3.connect(database) as connection: + connection.executescript(""" + CREATE TABLE push_subscriptions ( + session_id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + subscription_json TEXT NOT NULL + ); + CREATE TABLE 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 + ); + INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}'); + INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL); + """) + + store = PushSubscriptionStore(database) + + assert store.deadline_preferences("device-a")["reminder_days"] == 2 + + @pytest.mark.anyio async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch): pages = { diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py index 5379e36..7f2fa8a 100644 --- a/tests/test_push_frontend.py +++ b/tests/test_push_frontend.py @@ -19,6 +19,7 @@ const deadlineControl = { addEventListener:(_name, callback) => state.deadlineChange = callback, }; const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = 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 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'}}; }}; @@ -27,7 +28,7 @@ const registration = {pushManager:{ subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; }, }}; const feature = createPushNotifications({ - control, status, deadlineControl, deadlineStatus, deadlineHour, + control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, 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'}; }, @@ -80,7 +81,7 @@ process.stdout.write(JSON.stringify(state)); 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 09:00 local time." + assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 2 days." def test_deadline_setup_subscribes_once_and_persists_selected_local_hour(): @@ -100,7 +101,35 @@ process.stdout.write(JSON.stringify(state)); body = json.loads(result["requests"][-1][2]) assert body["enabled"] is True assert body["reminder_hour"] == 8 - assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time." + assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time, next 2 days." + + +def test_deadline_setup_persists_and_restores_confirmed_horizon(): + saved = run_scenario(""" +state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,public_key:'AQID'}; +state.current = existing; +await feature.init(); +deadlineDays.value = '7'; +await feature.changeDeadline(); +process.stdout.write(JSON.stringify(state)); +""") + + assert json.loads(saved["requests"][-1][2])["reminder_days"] == 7 + assert saved["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 7 days." + + restored = run_scenario(""" +state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,reminder_days:0,public_key:'AQID'}; +state.current = existing; +await feature.init(); +process.stdout.write(JSON.stringify({days:deadlineDays.value, readiness:feature.deadlineReadiness()})); +""") + assert restored == { + "days": "0", + "readiness": { + "state": "complete", + "detail": "Deadline reminders enabled for 17:00 local time, due today.", + }, + } def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete(): @@ -112,7 +141,7 @@ process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineCo """) assert restored == { "hour": "17", "checked": True, - "readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time."}, + "readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time, next 2 days."}, } denied = run_scenario(""" @@ -142,6 +171,8 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller(): assert 'id="push-update-status"' in html assert 'id="push-deadlines"' in html assert 'id="push-deadline-hour"' in html + assert 'id="push-deadline-days"' in html + assert 'id="device-setup-deadline-days"' in html assert 'id="push-deadline-status"' in html assert '' in html assert "createPushNotifications({" in dashboard diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py index fa40c49..5952b72 100644 --- a/tests/test_push_notifications.py +++ b/tests/test_push_notifications.py @@ -980,6 +980,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe( "deadline_enabled": False, "timezone": "UTC", "reminder_hour": 9, + "reminder_days": 2, } assert await main.subscribe_push(payload, request) == {"subscribed": True} assert (await main.push_status(request))["subscribed"] is True @@ -1002,15 +1003,17 @@ async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monk 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 + enabled=True, timezone="America/New_York", reminder_hour=9, reminder_days=7 ) assert await main.update_deadline_reminders(payload, request) == { "deadline_enabled": True, "timezone": "America/New_York", "reminder_hour": 9, + "reminder_days": 7, } assert store.deadline_preferences("session-a")["enabled"] is True + assert store.deadline_preferences("session-a")["reminder_days"] == 7 @pytest.mark.anyio