Merge pull request 'Schedule mobile notification quiet hours with one catch-up digest' (#1393) from timmy/1392-mobile-notification-quiet-hours into main
Some checks failed
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 6m30s
CI / release-candidate (push) Has been skipped

This commit is contained in:
rockachopa 2026-08-25 11:28:45 +00:00
commit 553a01217a
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; } .app-badge-control { min-height:44px; }
.push-update-control { min-height:44px; } .push-update-control { min-height:44px; }
.offline-work-controls input { width:20px; height:20px; } .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-work-controls button { min-height:44px; }
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } .offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.offline-today-readiness[hidden] { display:none; } .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> <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> <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> <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 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-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> <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, control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour, startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus, 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, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson, 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() { async function enableDeadline() {
deadlineControl.checked = true; deadlineControl.checked = true;
return changeDeadline(); return changeDeadline();
@ -340,6 +374,9 @@
deadlineControl?.addEventListener('change', changeDeadline); deadlineControl?.addEventListener('change', changeDeadline);
startDayControl?.addEventListener('change', changeStartDay); startDayControl?.addEventListener('change', changeStartDay);
followingControl?.addEventListener('change', changeFollowing); followingControl?.addEventListener('change', changeFollowing);
quietControl?.addEventListener('change', changeQuietHours);
quietStart?.addEventListener('change', changeQuietHours);
quietEnd?.addEventListener('change', changeQuietHours);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines); deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
configuration = await fetchJson('api/v1/push-subscription'); configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) { if (!configuration.available) {
@ -347,6 +384,7 @@
if (deadlineControl) deadlineControl.disabled = true; if (deadlineControl) deadlineControl.disabled = true;
if (startDayControl) startDayControl.disabled = true; if (startDayControl) startDayControl.disabled = true;
if (followingControl) followingControl.disabled = true; if (followingControl) followingControl.disabled = true;
if (quietControl) quietControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.'; status.textContent = 'New update notifications are not available on this server.';
return; return;
} }
@ -354,6 +392,9 @@
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled); if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled); if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
if (followingControl) followingControl.checked = Boolean(configuration.following_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 (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2); if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9); if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
@ -367,8 +408,11 @@
if (followingStatus) followingStatus.textContent = configuration.following_enabled if (followingStatus) followingStatus.textContent = configuration.following_enabled
? 'Following change alerts enabled for this device.' ? 'Following change alerts enabled for this device.'
: 'Following change alerts are off 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(); 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 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[ StepUpAction = Literal[
"merge_pull", "merge_pull",
"delete_source_branch", "delete_source_branch",
@ -2529,6 +2553,9 @@ async def push_status(request: Request):
following_preferences = await asyncio.to_thread( following_preferences = await asyncio.to_thread(
_push_subscription_store.following_preferences, device_id _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( delivery_health = await asyncio.to_thread(
_push_subscription_store.delivery_health, device_id _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_timezone": start_day_preferences["timezone"],
"start_day_reminder_hour": start_day_preferences["reminder_hour"], "start_day_reminder_hour": start_day_preferences["reminder_hour"],
"following_enabled": following_preferences["enabled"], "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, "delivery_health": delivery_health,
} }
@ -2712,6 +2743,31 @@ async def update_following_notifications(
return {"following_enabled": payload.enabled} 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") @app.patch("/api/v1/push-subscription/deadlines/snooze")
async def snooze_deadline_reminder(request: Request): async def snooze_deadline_reminder(request: Request):
device_id = await dashboard_auth.session_management_id( device_id = await dashboard_auth.session_management_id(

View File

@ -110,6 +110,7 @@ async def dispatch_following_changes(
lease_seconds: float = 60.0, lease_seconds: float = 60.0,
send_timeout_seconds: float = 10.0, send_timeout_seconds: float = 10.0,
max_concurrency: int = 8, max_concurrency: int = 8,
now: float | None = None,
) -> int: ) -> int:
"""Notify opted-in devices once for each privacy-safe Following change set.""" """Notify opted-in devices once for each privacy-safe Following change set."""
if not configuration.enabled: if not configuration.enabled:
@ -119,13 +120,15 @@ async def dispatch_following_changes(
store.acquire_dispatch_lease, store.acquire_dispatch_lease,
owner, owner,
channel="following", 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), lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
) )
if not acquired: if not acquired:
return 0 return 0
try: try:
devices = await asyncio.to_thread(store.following_notification_devices) devices = await asyncio.to_thread(
store.following_notification_devices, now=now
)
if not devices: if not devices:
return 0 return 0
snapshot = await following() snapshot = await following()
@ -174,17 +177,25 @@ async def dispatch_following_changes(
device for device in pending if statuses.get(device.session_id) == "active" device for device in pending if statuses.get(device.session_id) == "active"
] ]
count = min(len(changed), 50) 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)) semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def dispatch_device(device) -> int: async def dispatch_device(device) -> int:
async with semaphore: 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( still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease, store.acquire_dispatch_lease,
owner, owner,
@ -241,6 +252,7 @@ async def dispatch_unread_updates(
max_concurrency: int = 8, max_concurrency: int = 8,
max_individual_notifications: int = 3, max_individual_notifications: int = 3,
endpoint_validator: Callable[[str], Awaitable[str]] | None = None, endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
now: float | None = None,
) -> int: ) -> int:
if not configuration.enabled: if not configuration.enabled:
return 0 return 0
@ -249,7 +261,7 @@ async def dispatch_unread_updates(
store.acquire_dispatch_lease, store.acquire_dispatch_lease,
owner, owner,
channel="unread", channel="unread",
now=time.time(), now=time.time() if now is None else now,
lease_seconds=lease_seconds, lease_seconds=lease_seconds,
) )
if not acquired: if not acquired:
@ -265,7 +277,9 @@ async def dispatch_unread_updates(
} }
unread_count = min(len(thread_revisions), 9999) unread_count = min(len(thread_revisions), 9999)
await asyncio.to_thread(store.reconcile_unread, thread_revisions) 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: if session_statuses is not None:
try: try:
statuses = await session_statuses( statuses = await session_statuses(
@ -306,10 +320,10 @@ async def dispatch_unread_updates(
for thread_revision in delivery.thread_revisions for thread_revision in delivery.thread_revisions
if thread_revision not in digest_pending 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) :max(0, max_individual_notifications)
] ]
overflow_revisions = ( overflow_revisions = delivery.thread_revisions if delivery.catch_up else (
delivery.digest_revisions delivery.digest_revisions
+ new_revisions[len(individual_revisions):] + new_revisions[len(individual_revisions):]
) )
@ -385,10 +399,22 @@ async def dispatch_unread_updates(
return count return count
payload = json.dumps( payload = json.dumps(
{ {
"title": f"{len(overflow_revisions)} new work updates", "title": (
"body": "Tap to review them in Stackchain.", 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", "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), "update_count": len(overflow_revisions),
"unread_count": unread_count, "unread_count": unread_count,
}, },

View File

@ -7,7 +7,9 @@ import sqlite3
import time import time
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone as datetime_timezone
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from src.private_state import connect_private_sqlite from src.private_state import connect_private_sqlite
from src.state_encryption import ( from src.state_encryption import (
@ -22,6 +24,7 @@ class PushDelivery:
subscription: dict subscription: dict
thread_revisions: tuple[tuple[int, str], ...] thread_revisions: tuple[tuple[int, str], ...]
digest_revisions: tuple[tuple[int, str], ...] = () digest_revisions: tuple[tuple[int, str], ...] = ()
catch_up: bool = False
@property @property
def thread_ids(self) -> tuple[int, ...]: def thread_ids(self) -> tuple[int, ...]:
@ -57,6 +60,7 @@ class FollowingNotificationDevice:
session_id: str session_id: str
subscription: dict subscription: dict
delivered_fingerprint: str | None delivered_fingerprint: str | None
catch_up: bool = False
class DisabledPushSubscriptionStore: class DisabledPushSubscriptionStore:
@ -91,10 +95,13 @@ class DisabledPushSubscriptionStore:
def following_preferences(self, session_id: str) -> dict: def following_preferences(self, session_id: str) -> dict:
return {"enabled": False} 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 [] return []
def claim_unseen(self, thread_revisions) -> list[PushDelivery]: def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]:
return [] return []
def acquire_dispatch_lease(self, *args, **kwargs) -> bool: def acquire_dispatch_lease(self, *args, **kwargs) -> bool:
@ -133,6 +140,9 @@ class DisabledPushSubscriptionStore:
def set_following_preferences(self, *args, **kwargs) -> None: def set_following_preferences(self, *args, **kwargs) -> None:
return None return None
def set_quiet_hours(self, *args, **kwargs) -> None:
return None
def mark_following_delivered(self, *args, **kwargs) -> None: def mark_following_delivered(self, *args, **kwargs) -> None:
return None return None
@ -183,6 +193,16 @@ def _revisions(
return tuple(sorted(normalized.items())) 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: class PushSubscriptionStore:
"""Durable, device-bound Web Push subscriptions and delivery deduplication.""" """Durable, device-bound Web Push subscriptions and delivery deduplication."""
@ -262,6 +282,16 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE 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 = { delivery_columns = {
@ -612,20 +642,64 @@ class PushSubscriptionStore:
).fetchone() ).fetchone()
return {"enabled": bool(row[0]) if row else False} 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: with self._connect() as connection:
rows = connection.execute( 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 FROM push_subscriptions s
JOIN push_following_preferences p ON p.session_id = s.session_id 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""" WHERE p.enabled = 1 ORDER BY s.session_id"""
).fetchall() ).fetchall()
return [ devices = []
FollowingNotificationDevice( for row in rows:
row[0], self._open_subscription(row[0], row[1]), row[2] if row[3] and _inside_quiet_hours(
) now=checked_at, start=row[4], end=row[5], timezone=row[6]
for row in rows ):
] 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: def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
with self._connect() as connection: with self._connect() as connection:
@ -634,9 +708,14 @@ class PushSubscriptionStore:
WHERE session_id = ? AND enabled = 1""", WHERE session_id = ? AND enabled = 1""",
(fingerprint, session_id), (fingerprint, session_id),
) )
connection.execute(
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
(session_id,),
)
def claim_unseen( 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]: ) -> list[PushDelivery]:
candidates = _revisions(thread_revisions) candidates = _revisions(thread_revisions)
if not candidates: if not candidates:
@ -647,6 +726,21 @@ class PushSubscriptionStore:
).fetchall() ).fetchall()
deliveries = [] deliveries = []
for session_id, encoded in rows: 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 = { delivered = {
row[0]: row[1] row[0]: row[1]
for row in connection.execute( for row in connection.execute(
@ -700,6 +794,7 @@ class PushSubscriptionStore:
self._open_subscription(session_id, encoded), self._open_subscription(session_id, encoded),
unseen, unseen,
tuple(digest_revisions), tuple(digest_revisions),
catch_up,
) )
) )
return deliveries return deliveries
@ -770,3 +865,7 @@ class PushSubscriptionStore:
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?", "DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
((session_id, thread_id) for thread_id, _revision in values), ((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, addEventListener:(_name, callback) => state.followingChange = callback,
}; };
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }}; 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 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 testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
const deadlineSnooze = {hidden:true}; const deadlineSnooze = {hidden:true};
@ -48,6 +52,7 @@ const feature = createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour, startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus, followingControl, followingStatus,
quietControl, quietStart, quietEnd, quietStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; }, onReviewDeadlines:() => { state.reviewed = true; },
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }}, 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 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(): def test_degraded_device_can_run_a_test_notification_and_show_recovery():
result = run_scenario(""" result = run_scenario("""
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}}; 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 os
import sqlite3 import sqlite3
import time import time
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
import httpx 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} 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 @pytest.mark.anyio
async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path): async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3") 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 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 @pytest.mark.anyio
async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch): async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3") 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 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): def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
state_dir = tmp_path / "push-state" state_dir = tmp_path / "push-state"
previous_umask = os.umask(0) 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) disabled = factory(tmp_path / "disabled.sqlite3", push_enabled=False)
assert disabled.is_subscribed("session-a") is False assert disabled.is_subscribed("session-a") is False
assert disabled.deadline_preferences("session-a")["enabled"] 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() assert not (tmp_path / "disabled.sqlite3").exists()
with pytest.raises(RuntimeError, match="encryption key"): with pytest.raises(RuntimeError, match="encryption key"):
factory(tmp_path / "enabled.sqlite3", push_enabled=True) 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_timezone": "UTC",
"start_day_reminder_hour": 9, "start_day_reminder_hour": 9,
"following_enabled": False, "following_enabled": False,
"quiet_hours_enabled": False,
"quiet_hours_start": "22:00",
"quiet_hours_end": "07:00",
"quiet_hours_timezone": "UTC",
"delivery_health": {}, "delivery_health": {},
} }
assert await main.subscribe_push(payload, request) == {"subscribed": True} assert await main.subscribe_push(payload, request) == {"subscribed": True}