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}