From cc74b7b8db9d85ef6003da6e11cc2ca792d5c592 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 11:17:40 +0000 Subject: [PATCH] feat: schedule mobile notification quiet hours (Closes #1392) --- frontend/dashboard.css | 3 + frontend/index.html | 6 + frontend/push-notifications.js | 46 +++++- src/main.py | 56 +++++++ src/push_notifications.py | 58 +++++-- src/push_subscription_store.py | 121 +++++++++++++-- ...mobile_notification_quiet_hours_release.py | 44 ++++++ tests/test_push_frontend.py | 40 +++++ tests/test_push_notifications.py | 141 ++++++++++++++++++ 9 files changed, 487 insertions(+), 28 deletions(-) create mode 100644 tests/e2e/test_mobile_notification_quiet_hours_release.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index acd74fb..b59d10c 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -264,6 +264,9 @@ textarea { resize: vertical; min-height: 120px; } .app-badge-control { min-height:44px; } .push-update-control { min-height:44px; } .offline-work-controls input { width:20px; height:20px; } +.push-quiet-hours-times { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; width:100%; } +.push-quiet-hours-times label { min-width:0; } +.offline-work-controls .push-quiet-hours-times input { box-sizing:border-box; width:100%; min-width:0; height:44px; } .offline-work-controls button { min-height:44px; } .offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } .offline-today-readiness[hidden] { display:none; } diff --git a/frontend/index.html b/frontend/index.html index 93a80ff..5dcfebe 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -224,6 +224,12 @@ + +
+ + +
+ diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js index 6024d23..03ea3a8 100644 --- a/frontend/push-notifications.js +++ b/frontend/push-notifications.js @@ -5,6 +5,10 @@ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, startDayControl, startDayStatus, startDayHour, followingControl, followingStatus, + quietControl = globalThis.document?.querySelector('#push-quiet-hours'), + quietStart = globalThis.document?.querySelector('#push-quiet-start'), + quietEnd = globalThis.document?.querySelector('#push-quiet-end'), + quietStatus = globalThis.document?.querySelector('#push-quiet-status'), deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines, notification, serviceWorker, fetchJson, }) { @@ -302,6 +306,36 @@ } } + async function changeQuietHours() { + if (!quietControl) return false; + for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true; + try { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + await fetchJson('api/v1/push-subscription/quiet-hours', { + method:'PUT', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({ + enabled:quietControl.checked, + start:quietStart?.value || '22:00', + end:quietEnd?.value || '07:00', + timezone, + }), + }); + configuration.quiet_hours_enabled = quietControl.checked; + configuration.quiet_hours_start = quietStart?.value || '22:00'; + configuration.quiet_hours_end = quietEnd?.value || '07:00'; + quietStatus.textContent = quietControl.checked + ? `Routine alerts paused from ${configuration.quiet_hours_start} to ${configuration.quiet_hours_end} local time.` + : 'Routine alert quiet hours are off for this device.'; + return true; + } catch (_error) { + quietStatus.textContent = 'Could not save quiet hours. Check your connection and try again.'; + return false; + } finally { + for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = false; + } + } + async function enableDeadline() { deadlineControl.checked = true; return changeDeadline(); @@ -340,6 +374,9 @@ deadlineControl?.addEventListener('change', changeDeadline); startDayControl?.addEventListener('change', changeStartDay); followingControl?.addEventListener('change', changeFollowing); + quietControl?.addEventListener('change', changeQuietHours); + quietStart?.addEventListener('change', changeQuietHours); + quietEnd?.addEventListener('change', changeQuietHours); deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines); configuration = await fetchJson('api/v1/push-subscription'); if (!configuration.available) { @@ -347,6 +384,7 @@ if (deadlineControl) deadlineControl.disabled = true; if (startDayControl) startDayControl.disabled = true; if (followingControl) followingControl.disabled = true; + if (quietControl) quietControl.disabled = true; status.textContent = 'New update notifications are not available on this server.'; return; } @@ -354,6 +392,9 @@ if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled); if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled); if (followingControl) followingControl.checked = Boolean(configuration.following_enabled); + if (quietControl) quietControl.checked = Boolean(configuration.quiet_hours_enabled); + if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00'; + if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00'; if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9); if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2); if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9); @@ -367,8 +408,11 @@ if (followingStatus) followingStatus.textContent = configuration.following_enabled ? 'Following change alerts enabled for this device.' : 'Following change alerts are off for this device.'; + if (quietStatus) quietStatus.textContent = configuration.quiet_hours_enabled + ? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.` + : 'Routine alert quiet hours are off for this device.'; renderDeadlineSnooze(); } - return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; + return {init, change, changeDeadline, changeStartDay, changeFollowing, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; }); diff --git a/src/main.py b/src/main.py index 0151c9e..204ccff 100644 --- a/src/main.py +++ b/src/main.py @@ -522,6 +522,30 @@ class FollowingNotificationPayload(BaseModel): enabled: bool +class QuietHoursPayload(BaseModel): + enabled: bool + start: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$") + end: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$") + timezone: str = Field(min_length=1, max_length=64) + + @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 + + @model_validator(mode="after") + def distinct_boundaries(self): + if self.start == self.end: + raise ValueError("quiet hours require distinct start and end times") + return self + + StepUpAction = Literal[ "merge_pull", "delete_source_branch", @@ -2529,6 +2553,9 @@ async def push_status(request: Request): following_preferences = await asyncio.to_thread( _push_subscription_store.following_preferences, device_id ) + quiet_hours = await asyncio.to_thread( + _push_subscription_store.quiet_hours, device_id + ) delivery_health = await asyncio.to_thread( _push_subscription_store.delivery_health, device_id ) @@ -2545,6 +2572,10 @@ async def push_status(request: Request): "start_day_timezone": start_day_preferences["timezone"], "start_day_reminder_hour": start_day_preferences["reminder_hour"], "following_enabled": following_preferences["enabled"], + "quiet_hours_enabled": quiet_hours["enabled"], + "quiet_hours_start": quiet_hours["start"], + "quiet_hours_end": quiet_hours["end"], + "quiet_hours_timezone": quiet_hours["timezone"], "delivery_health": delivery_health, } @@ -2712,6 +2743,31 @@ async def update_following_notifications( return {"following_enabled": payload.enabled} +@app.put("/api/v1/push-subscription/quiet-hours") +async def update_quiet_hours(payload: QuietHoursPayload, 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_quiet_hours, + device_id, + enabled=payload.enabled, + start=payload.start, + end=payload.end, + timezone=payload.timezone, + ) + return { + "quiet_hours_enabled": payload.enabled, + "quiet_hours_start": payload.start, + "quiet_hours_end": payload.end, + "quiet_hours_timezone": payload.timezone, + } + + @app.patch("/api/v1/push-subscription/deadlines/snooze") async def snooze_deadline_reminder(request: Request): device_id = await dashboard_auth.session_management_id( diff --git a/src/push_notifications.py b/src/push_notifications.py index dcda660..ae6a59c 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -110,6 +110,7 @@ async def dispatch_following_changes( lease_seconds: float = 60.0, send_timeout_seconds: float = 10.0, max_concurrency: int = 8, + now: float | None = None, ) -> int: """Notify opted-in devices once for each privacy-safe Following change set.""" if not configuration.enabled: @@ -119,13 +120,15 @@ async def dispatch_following_changes( store.acquire_dispatch_lease, owner, channel="following", - now=time.time(), + now=time.time() if now is None else now, lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0), ) if not acquired: return 0 try: - devices = await asyncio.to_thread(store.following_notification_devices) + devices = await asyncio.to_thread( + store.following_notification_devices, now=now + ) if not devices: return 0 snapshot = await following() @@ -174,17 +177,25 @@ async def dispatch_following_changes( device for device in pending if statuses.get(device.session_id) == "active" ] count = min(len(changed), 50) - payload = json.dumps({ - "title": f"{count} watched item{'s' if count != 1 else ''} changed", - "body": "Open Following to review the latest activity.", - "route": "#/my-work/following", - "tag": f"stackchain-following-{fingerprint[:16]}", - "following_count": count, - }, separators=(",", ":")) semaphore = asyncio.Semaphore(max(1, max_concurrency)) async def dispatch_device(device) -> int: async with semaphore: + payload = json.dumps({ + "title": ( + f"{count} watched update{'s' if count != 1 else ''} while alerts were paused" + if device.catch_up + else f"{count} watched item{'s' if count != 1 else ''} changed" + ), + "body": "Open Following to review the latest activity.", + "route": "#/my-work/following", + "tag": ( + "stackchain-following-catch-up" + if device.catch_up + else f"stackchain-following-{fingerprint[:16]}" + ), + "following_count": count, + }, separators=(",", ":")) still_owner = await asyncio.to_thread( store.acquire_dispatch_lease, owner, @@ -241,6 +252,7 @@ async def dispatch_unread_updates( max_concurrency: int = 8, max_individual_notifications: int = 3, endpoint_validator: Callable[[str], Awaitable[str]] | None = None, + now: float | None = None, ) -> int: if not configuration.enabled: return 0 @@ -249,7 +261,7 @@ async def dispatch_unread_updates( store.acquire_dispatch_lease, owner, channel="unread", - now=time.time(), + now=time.time() if now is None else now, lease_seconds=lease_seconds, ) if not acquired: @@ -265,7 +277,9 @@ async def dispatch_unread_updates( } unread_count = min(len(thread_revisions), 9999) await asyncio.to_thread(store.reconcile_unread, thread_revisions) - deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions) + deliveries = await asyncio.to_thread( + store.claim_unseen, thread_revisions, now=now + ) if session_statuses is not None: try: statuses = await session_statuses( @@ -306,10 +320,10 @@ async def dispatch_unread_updates( for thread_revision in delivery.thread_revisions if thread_revision not in digest_pending ) - individual_revisions = new_revisions[ + individual_revisions = () if delivery.catch_up else new_revisions[ :max(0, max_individual_notifications) ] - overflow_revisions = ( + overflow_revisions = delivery.thread_revisions if delivery.catch_up else ( delivery.digest_revisions + new_revisions[len(individual_revisions):] ) @@ -385,10 +399,22 @@ async def dispatch_unread_updates( return count payload = json.dumps( { - "title": f"{len(overflow_revisions)} new work updates", - "body": "Tap to review them in Stackchain.", + "title": ( + f"{len(overflow_revisions)} updates while alerts were paused" + if delivery.catch_up + else f"{len(overflow_revisions)} new work updates" + ), + "body": ( + "Open Updates to catch up in Stackchain." + if delivery.catch_up + else "Tap to review them in Stackchain." + ), "route": "#/my-work/updates", - "tag": "stackchain-update-digest", + "tag": ( + "stackchain-update-catch-up" + if delivery.catch_up + else "stackchain-update-digest" + ), "update_count": len(overflow_revisions), "unread_count": unread_count, }, diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py index 2de8479..189a2b8 100644 --- a/src/push_subscription_store.py +++ b/src/push_subscription_store.py @@ -7,7 +7,9 @@ import sqlite3 import time from collections.abc import Iterable, Mapping from dataclasses import dataclass +from datetime import datetime, timezone as datetime_timezone from pathlib import Path +from zoneinfo import ZoneInfo from src.private_state import connect_private_sqlite from src.state_encryption import ( @@ -22,6 +24,7 @@ class PushDelivery: subscription: dict thread_revisions: tuple[tuple[int, str], ...] digest_revisions: tuple[tuple[int, str], ...] = () + catch_up: bool = False @property def thread_ids(self) -> tuple[int, ...]: @@ -57,6 +60,7 @@ class FollowingNotificationDevice: session_id: str subscription: dict delivered_fingerprint: str | None + catch_up: bool = False class DisabledPushSubscriptionStore: @@ -91,10 +95,13 @@ class DisabledPushSubscriptionStore: def following_preferences(self, session_id: str) -> dict: return {"enabled": False} - def following_notification_devices(self) -> list[FollowingNotificationDevice]: + def quiet_hours(self, session_id: str) -> dict: + return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"} + + def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]: return [] - def claim_unseen(self, thread_revisions) -> list[PushDelivery]: + def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]: return [] def acquire_dispatch_lease(self, *args, **kwargs) -> bool: @@ -133,6 +140,9 @@ class DisabledPushSubscriptionStore: def set_following_preferences(self, *args, **kwargs) -> None: return None + def set_quiet_hours(self, *args, **kwargs) -> None: + return None + def mark_following_delivered(self, *args, **kwargs) -> None: return None @@ -183,6 +193,16 @@ def _revisions( return tuple(sorted(normalized.items())) +def _inside_quiet_hours(*, now: float, start: str, end: str, timezone: str) -> bool: + local = datetime.fromtimestamp(now, tz=datetime_timezone.utc).astimezone(ZoneInfo(timezone)) + minute = local.hour * 60 + local.minute + start_minute = int(start[:2]) * 60 + int(start[3:]) + end_minute = int(end[:2]) * 60 + int(end[3:]) + if start_minute < end_minute: + return start_minute <= minute < end_minute + return minute >= start_minute or minute < end_minute + + class PushSubscriptionStore: """Durable, device-bound Web Push subscriptions and delivery deduplication.""" @@ -262,6 +282,16 @@ class PushSubscriptionStore: FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS push_quiet_hours ( + session_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + start_time TEXT NOT NULL DEFAULT '22:00', + end_time TEXT NOT NULL DEFAULT '07:00', + timezone TEXT NOT NULL DEFAULT 'UTC', + suppressed INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) + ON DELETE CASCADE + ); """ ) delivery_columns = { @@ -612,20 +642,64 @@ class PushSubscriptionStore: ).fetchone() return {"enabled": bool(row[0]) if row else False} - def following_notification_devices(self) -> list[FollowingNotificationDevice]: + def set_quiet_hours( + self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str + ) -> None: + with self._connect() as connection: + connection.execute( + """INSERT INTO push_quiet_hours( + session_id, enabled, start_time, end_time, timezone + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + enabled = excluded.enabled, + start_time = excluded.start_time, + end_time = excluded.end_time, + timezone = excluded.timezone, + suppressed = CASE WHEN excluded.enabled = 1 THEN suppressed ELSE 0 END""", + (session_id, int(enabled), start, end, timezone), + ) + + def quiet_hours(self, session_id: str) -> dict: + with self._connect() as connection: + row = connection.execute( + """SELECT enabled, start_time, end_time, timezone + FROM push_quiet_hours WHERE session_id = ?""", + (session_id,), + ).fetchone() + return { + "enabled": bool(row[0]) if row else False, + "start": row[1] if row else "22:00", + "end": row[2] if row else "07:00", + "timezone": row[3] if row else "UTC", + } + + def following_notification_devices( + self, *, now: float | None = None + ) -> list[FollowingNotificationDevice]: + checked_at = time.time() if now is None else now with self._connect() as connection: rows = connection.execute( - """SELECT s.session_id, s.subscription_json, p.delivered_fingerprint + """SELECT s.session_id, s.subscription_json, p.delivered_fingerprint, + q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed FROM push_subscriptions s JOIN push_following_preferences p ON p.session_id = s.session_id + LEFT JOIN push_quiet_hours q ON q.session_id = s.session_id WHERE p.enabled = 1 ORDER BY s.session_id""" ).fetchall() - return [ - FollowingNotificationDevice( - row[0], self._open_subscription(row[0], row[1]), row[2] - ) - for row in rows - ] + devices = [] + for row in rows: + if row[3] and _inside_quiet_hours( + now=checked_at, start=row[4], end=row[5], timezone=row[6] + ): + connection.execute( + "UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?", + (row[0],), + ) + continue + devices.append(FollowingNotificationDevice( + row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7]) + )) + return devices def mark_following_delivered(self, session_id: str, fingerprint: str) -> None: with self._connect() as connection: @@ -634,9 +708,14 @@ class PushSubscriptionStore: WHERE session_id = ? AND enabled = 1""", (fingerprint, session_id), ) + connection.execute( + "UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?", + (session_id,), + ) 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]], + *, now: float | None = None, ) -> list[PushDelivery]: candidates = _revisions(thread_revisions) if not candidates: @@ -647,6 +726,21 @@ class PushSubscriptionStore: ).fetchall() deliveries = [] for session_id, encoded in rows: + quiet = connection.execute( + """SELECT enabled, start_time, end_time, timezone, suppressed + FROM push_quiet_hours WHERE session_id = ?""", + (session_id,), + ).fetchone() + catch_up = bool(quiet and quiet[4]) + if quiet and quiet[0] and _inside_quiet_hours( + now=time.time() if now is None else now, + start=quiet[1], end=quiet[2], timezone=quiet[3], + ): + connection.execute( + "UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?", + (session_id,), + ) + continue delivered = { row[0]: row[1] for row in connection.execute( @@ -700,6 +794,7 @@ class PushSubscriptionStore: self._open_subscription(session_id, encoded), unseen, tuple(digest_revisions), + catch_up, ) ) return deliveries @@ -770,3 +865,7 @@ class PushSubscriptionStore: "DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?", ((session_id, thread_id) for thread_id, _revision in values), ) + connection.execute( + "UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?", + (session_id,), + ) diff --git a/tests/e2e/test_mobile_notification_quiet_hours_release.py b/tests/e2e/test_mobile_notification_quiet_hours_release.py new file mode 100644 index 0000000..1400171 --- /dev/null +++ b/tests/e2e/test_mobile_notification_quiet_hours_release.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import os +import re +from pathlib import Path + +import pytest + +if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": + pytest.skip("rendered mobile quiet-hours checks run only in the browser gate", allow_module_level=True) +pytest.importorskip("playwright.sync_api") +from playwright.sync_api import expect, sync_playwright + + +ROOT = Path(__file__).resolve().parents[2] +FRONTEND = ROOT / "frontend" + + +@pytest.mark.parametrize("viewport", [ + {"width": 320, "height": 568}, + {"width": 390, "height": 844}, +]) +def test_notification_quiet_hours_are_phone_usable_without_horizontal_scroll(viewport): + html = re.sub(r'', "", (FRONTEND / "index.html").read_text()) + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport=viewport) + page.set_content(html) + page.add_style_tag(path=FRONTEND / "dashboard.css") + page.locator("#work-settings-toggle").click() + + toggle = page.locator('label[for="push-quiet-hours"]') + start = page.locator("#push-quiet-start") + end = page.locator("#push-quiet-end") + expect(toggle).to_be_visible() + expect(start).to_be_visible() + expect(end).to_be_visible() + expect(start).to_have_value("22:00") + expect(end).to_have_value("07:00") + for control in (toggle, start, end): + bounds = control.bounding_box() + assert bounds and bounds["height"] >= 44 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + browser.close() diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py index 701dd3f..0d5c9c0 100644 --- a/tests/test_push_frontend.py +++ b/tests/test_push_frontend.py @@ -34,6 +34,10 @@ const followingControl = { addEventListener:(_name, callback) => state.followingChange = callback, }; const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }}; +const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback}; +const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback}; +const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback}; +const quietStatus = {set textContent(value) { state.quietText = value; }, get textContent() { return state.quietText; }}; const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }}; const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback}; const deadlineSnooze = {hidden:true}; @@ -48,6 +52,7 @@ const feature = createPushNotifications({ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, startDayControl, startDayStatus, startDayHour, followingControl, followingStatus, + quietControl, quietStart, quietEnd, quietStatus, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines:() => { state.reviewed = true; }, notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }}, @@ -114,6 +119,41 @@ def test_device_settings_render_and_wire_the_following_alert_preference(): assert "followingStatus:qs('#push-following-status')" in dashboard +def test_quiet_hours_are_restored_and_saved_as_one_local_schedule(): + result = run_scenario(""" +state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'}; +state.current = existing; +await feature.init(); +quietStart.value = '22:15'; +quietEnd.value = '07:30'; +await state.quietStartChange(); +process.stdout.write(JSON.stringify({requests:state.requests, checked:quietControl.checked, start:quietStart.value, end:quietEnd.value, text:state.quietText})); +""") + + assert result["checked"] is True + assert result["requests"][-1][0:2] == ["api/v1/push-subscription/quiet-hours", "PUT"] + body = json.loads(result["requests"][-1][2]) + assert body["enabled"] is True + assert body["start"] == "22:15" + assert body["end"] == "07:30" + assert isinstance(body["timezone"], str) and body["timezone"] + assert result["text"] == "Routine alerts paused from 22:15 to 07:30 local time." + + +def test_device_settings_render_and_wire_mobile_quiet_hours(): + index = INDEX.read_text() + dashboard = DASHBOARD.read_text() + + assert 'id="push-quiet-hours" type="checkbox"' in index + assert 'id="push-quiet-start" type="time"' in index + assert 'id="push-quiet-end" type="time"' in index + assert 'id="push-quiet-status" role="status" aria-live="polite"' in index + module = MODULE.read_text() + assert "querySelector('#push-quiet-hours')" in module + assert "querySelector('#push-quiet-start')" in module + assert "querySelector('#push-quiet-end')" in module + + def test_degraded_device_can_run_a_test_notification_and_show_recovery(): result = run_scenario(""" state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}}; diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py index 6330562..e16dea2 100644 --- a/tests/test_push_notifications.py +++ b/tests/test_push_notifications.py @@ -3,6 +3,7 @@ import asyncio import os import sqlite3 import time +from datetime import datetime, timezone from types import SimpleNamespace import httpx @@ -62,6 +63,67 @@ def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_p assert store.following_preferences("session-b") == {"enabled": False} +def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("session-a", { + "endpoint": "https://push.example/session-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_quiet_hours( + "session-a", enabled=True, start="22:00", end="07:00", timezone="UTC" + ) + + inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp() + after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp() + + assert store.quiet_hours("session-a") == { + "enabled": True, "start": "22:00", "end": "07:00", "timezone": "UTC" + } + assert store.claim_unseen({42: "r1"}, now=inside) == [] + delivery = store.claim_unseen({42: "r1"}, now=after) + assert len(delivery) == 1 + assert delivery[0].thread_revisions == ((42, "r1"),) + assert delivery[0].catch_up is True + store.mark_delivered("session-a", delivery[0].thread_revisions) + assert store.claim_unseen({42: "r1"}, now=after) == [] + + +@pytest.mark.anyio +async def test_unread_dispatch_sends_one_catch_up_digest_after_quiet_hours(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("session-a", { + "endpoint": "https://push.example/session-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_quiet_hours( + "session-a", enabled=True, start="22:00", end="07:00", timezone="UTC" + ) + inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp() + after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp() + assert store.claim_unseen({41: "r1", 42: "r1"}, now=inside) == [] + sent = [] + + async def unread(): + return {"items": [ + {"id": 41, "updated_at": "r1"}, + {"id": 42, "updated_at": "r1"}, + ]} + + async def send(_subscription, payload): + sent.append(json.loads(payload)) + + config = PushConfiguration("public", "private", "mailto:ops@example.com") + assert await dispatch_unread_updates(store, config, unread, send, now=after) == 1 + assert sent == [{ + "title": "2 updates while alerts were paused", + "body": "Open Updates to catch up in Stackchain.", + "route": "#/my-work/updates", + "tag": "stackchain-update-catch-up", + "update_count": 2, + "unread_count": 2, + }] + + @pytest.mark.anyio async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") @@ -109,6 +171,43 @@ async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_ assert store.subscription_for_session("revoked") is None +@pytest.mark.anyio +async def test_following_changes_wait_for_quiet_hours_and_resume_as_catch_up(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("session-a", { + "endpoint": "https://push.example/session-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_following_preferences("session-a", enabled=True) + store.set_quiet_hours( + "session-a", enabled=True, start="22:00", end="07:00", timezone="UTC" + ) + inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp() + after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp() + sent = [] + + async def following(): + return {"items": [{ + "repository": "private/project", "kind": "issue", "number": 42, + "title": "Sensitive", "updated_at": "2026-08-25T23:00:00Z", + "has_unseen_change": True, + }]} + + async def send(_subscription, payload): + sent.append(json.loads(payload)) + + configuration = PushConfiguration("public", "private", "mailto:ops@example.com") + assert await dispatch_following_changes( + store, configuration, following, send, now=inside + ) == 0 + assert sent == [] + assert await dispatch_following_changes( + store, configuration, following, send, now=after + ) == 1 + assert sent[0]["title"] == "1 watched update while alerts were paused" + assert sent[0]["route"] == "#/my-work/following" + + @pytest.mark.anyio async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch): store = PushSubscriptionStore(tmp_path / "push.sqlite3") @@ -134,6 +233,43 @@ async def test_authenticated_device_controls_following_alerts_independently(tmp_ assert store.start_day_preferences("session-a")["enabled"] is False +@pytest.mark.anyio +async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("session-a", { + "endpoint": "https://push.example/session-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.QuietHoursPayload( + enabled=True, start="22:30", end="06:45", timezone="America/New_York" + ) + + result = await main.update_quiet_hours(payload, request) + + assert result == { + "quiet_hours_enabled": True, + "quiet_hours_start": "22:30", + "quiet_hours_end": "06:45", + "quiet_hours_timezone": "America/New_York", + } + status = await main.push_status(request) + assert {key: status[key] for key in result} == result + + +def test_quiet_hours_reject_equal_boundaries_and_invalid_timezone(): + with pytest.raises(ValueError): + main.QuietHoursPayload(enabled=True, start="22:00", end="22:00", timezone="UTC") + with pytest.raises(ValueError): + main.QuietHoursPayload(enabled=True, start="22:00", end="07:00", timezone="Moon/Base") + + def test_subscription_store_uses_private_filesystem_permissions(tmp_path): state_dir = tmp_path / "push-state" previous_umask = os.umask(0) @@ -217,6 +353,7 @@ def test_push_store_factory_only_requires_its_key_when_push_is_enabled( disabled = factory(tmp_path / "disabled.sqlite3", push_enabled=False) assert disabled.is_subscribed("session-a") is False assert disabled.deadline_preferences("session-a")["enabled"] is False + assert disabled.claim_unseen({42: "r1"}, now=0) == [] assert not (tmp_path / "disabled.sqlite3").exists() with pytest.raises(RuntimeError, match="encryption key"): factory(tmp_path / "enabled.sqlite3", push_enabled=True) @@ -1435,6 +1572,10 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe( "start_day_timezone": "UTC", "start_day_reminder_hour": 9, "following_enabled": False, + "quiet_hours_enabled": False, + "quiet_hours_start": "22:00", + "quiet_hours_end": "07:00", + "quiet_hours_timezone": "UTC", "delivery_health": {}, } assert await main.subscribe_push(payload, request) == {"subscribed": True} -- 2.43.0