Merge pull request 'Schedule mobile notification quiet hours with one catch-up digest' (#1393) from timmy/1392-mobile-notification-quiet-hours into main
This commit is contained in:
commit
553a01217a
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
56
src/main.py
56
src/main.py
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
):
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
|
||||||
|
(row[0],),
|
||||||
)
|
)
|
||||||
for row in rows
|
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,),
|
||||||
|
)
|
||||||
|
|
|
||||||
44
tests/e2e/test_mobile_notification_quiet_hours_release.py
Normal file
44
tests/e2e/test_mobile_notification_quiet_hours_release.py
Normal 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()
|
||||||
|
|
@ -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'}}};
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user