feat: schedule mobile notification quiet hours (Closes #1392)
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 committed 2026-08-25 11:17:40 +00:00
1 parent 2acd9431e0
commit cc74b7b8db
9 files changed
+487 -28

No files matched your search

@@ -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()
+40
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'}}};
+141
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}