feat: schedule mobile notification quiet hours (Closes #1392)
All checks were successful
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-25 11:17:40 +00:00
parent 2acd9431e0
commit cc74b7b8db
9 changed files with 487 additions and 28 deletions

View File

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

View File

@ -224,6 +224,12 @@
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-quiet-hours"><input id="push-quiet-hours" type="checkbox" /> Pause routine alerts on a schedule</label>
<div class="push-quiet-hours-times">
<label for="push-quiet-start">From <input id="push-quiet-start" type="time" value="22:00" /></label>
<label for="push-quiet-end">Until <input id="push-quiet-end" type="time" value="07:00" /></label>
</div>
<span class="small" id="push-quiet-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>

View File

@ -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};
});

View File

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

View File

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

View File

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

View File

@ -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'<script src="static/[^"]+"></script>', "", (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()

View File

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

View File

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