Merge pull request 'Send opt-in mobile deadline reminders into Agenda' (#710) from timmy/709-mobile-deadline-reminders into main
This commit is contained in:
commit
c503994be1
|
|
@ -6044,6 +6044,8 @@
|
||||||
const controller = createPushNotifications({
|
const controller = createPushNotifications({
|
||||||
control:qs('#push-updates'),
|
control:qs('#push-updates'),
|
||||||
status:qs('#push-update-status'),
|
status:qs('#push-update-status'),
|
||||||
|
deadlineControl:qs('#push-deadlines'),
|
||||||
|
deadlineStatus:qs('#push-deadline-status'),
|
||||||
notification:window.Notification,
|
notification:window.Notification,
|
||||||
serviceWorker:navigator.serviceWorker,
|
serviceWorker:navigator.serviceWorker,
|
||||||
fetchJson:fetchReviewJson,
|
fetchJson:fetchReviewJson,
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,8 @@
|
||||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
<span class="small" id="push-update-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>
|
||||||
|
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||||
else root.createPushNotifications = factory;
|
else root.createPushNotifications = factory;
|
||||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||||
control, status, notification, serviceWorker, fetchJson,
|
control, status, deadlineControl, deadlineStatus, notification, serviceWorker, fetchJson,
|
||||||
}) {
|
}) {
|
||||||
let configuration = null;
|
let configuration = null;
|
||||||
|
|
||||||
|
|
@ -20,7 +20,9 @@
|
||||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||||
await subscription?.unsubscribe?.();
|
await subscription?.unsubscribe?.();
|
||||||
control.checked = false;
|
control.checked = false;
|
||||||
|
if (deadlineControl) deadlineControl.checked = false;
|
||||||
status.textContent = 'New update notifications are off for this device.';
|
status.textContent = 'New update notifications are off for this device.';
|
||||||
|
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enable() {
|
async function enable() {
|
||||||
|
|
@ -60,20 +62,53 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeDeadline() {
|
||||||
|
deadlineControl.disabled = true;
|
||||||
|
try {
|
||||||
|
const registration = await serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (deadlineControl.checked && !subscription) {
|
||||||
|
deadlineControl.checked = false;
|
||||||
|
deadlineStatus.textContent = 'Enable new update notifications first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
|
await fetchJson('api/v1/push-subscription/deadlines', {
|
||||||
|
method:'PUT',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:9}),
|
||||||
|
});
|
||||||
|
deadlineStatus.textContent = deadlineControl.checked
|
||||||
|
? 'Deadline reminders enabled for 9:00 local time.'
|
||||||
|
: 'Deadline reminders are off for this device.';
|
||||||
|
} catch (error) {
|
||||||
|
deadlineControl.checked = !deadlineControl.checked;
|
||||||
|
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
|
||||||
|
} finally {
|
||||||
|
deadlineControl.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (!control || !notification || !serviceWorker) return;
|
if (!control || !notification || !serviceWorker) return;
|
||||||
control.addEventListener('change', change);
|
control.addEventListener('change', change);
|
||||||
|
deadlineControl?.addEventListener('change', changeDeadline);
|
||||||
configuration = await fetchJson('api/v1/push-subscription');
|
configuration = await fetchJson('api/v1/push-subscription');
|
||||||
if (!configuration.available) {
|
if (!configuration.available) {
|
||||||
control.disabled = true;
|
control.disabled = true;
|
||||||
|
if (deadlineControl) deadlineControl.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;
|
||||||
}
|
}
|
||||||
control.checked = Boolean(configuration.subscribed);
|
control.checked = Boolean(configuration.subscribed);
|
||||||
|
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||||
status.textContent = configuration.subscribed
|
status.textContent = configuration.subscribed
|
||||||
? 'New update notifications enabled for this device.'
|
? 'New update notifications enabled for this device.'
|
||||||
: 'New update notifications are off for this device.';
|
: 'New update notifications are off for this device.';
|
||||||
|
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||||
|
? `Deadline reminders enabled for ${configuration.reminder_hour}:00 local time.`
|
||||||
|
: 'Deadline reminders are off for this device.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {init, change};
|
return {init, change, changeDeadline};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,24 @@ self.addEventListener('push', event => {
|
||||||
const tag = String(payload.tag || '');
|
const tag = String(payload.tag || '');
|
||||||
const notificationId = Number(payload.notification_id);
|
const notificationId = Number(payload.notification_id);
|
||||||
const updateCount = Number(payload.update_count);
|
const updateCount = Number(payload.update_count);
|
||||||
|
const deadlineCount = Number(payload.deadline_count);
|
||||||
|
if (
|
||||||
|
route === '#/my-work/agenda'
|
||||||
|
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
|
||||||
|
&& Number.isSafeInteger(deadlineCount)
|
||||||
|
&& deadlineCount > 0
|
||||||
|
&& deadlineCount <= 50
|
||||||
|
) {
|
||||||
|
event.waitUntil(self.registration.showNotification(
|
||||||
|
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
|
||||||
|
{
|
||||||
|
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
|
||||||
|
tag,
|
||||||
|
data: {route},
|
||||||
|
}
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
route === '#/my-work/updates'
|
route === '#/my-work/updates'
|
||||||
&& tag === 'stackchain-update-digest'
|
&& tag === 'stackchain-update-digest'
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,17 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def assigned_issue_snapshot(*, limit: int = 50, max_pages: int = 20) -> dict:
|
||||||
|
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
|
||||||
|
items = []
|
||||||
|
for page in range(1, max_pages + 1):
|
||||||
|
result = await work_page("issue", page, limit)
|
||||||
|
items.extend(result["items"])
|
||||||
|
if not result["has_more"]:
|
||||||
|
return {"items": items, "complete": True}
|
||||||
|
return {"items": [], "complete": False}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
59
src/main.py
59
src/main.py
|
|
@ -48,7 +48,11 @@ from src.live_snapshot_store import (
|
||||||
)
|
)
|
||||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||||
from src.passkey_store import PasskeyStore
|
from src.passkey_store import PasskeyStore
|
||||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
from src.push_notifications import (
|
||||||
|
PushConfiguration,
|
||||||
|
dispatch_deadline_reminders,
|
||||||
|
dispatch_unread_updates,
|
||||||
|
)
|
||||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
|
|
@ -112,6 +116,13 @@ async def _push_poll_loop() -> None:
|
||||||
max_concurrency=max_concurrency,
|
max_concurrency=max_concurrency,
|
||||||
max_individual_notifications=max_individual_notifications,
|
max_individual_notifications=max_individual_notifications,
|
||||||
)
|
)
|
||||||
|
await dispatch_deadline_reminders(
|
||||||
|
_push_subscription_store,
|
||||||
|
_push_configuration(),
|
||||||
|
gitea_proxy.assigned_issue_snapshot,
|
||||||
|
session_active=dashboard_auth.managed_session_active,
|
||||||
|
send_timeout_seconds=send_timeout,
|
||||||
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -346,6 +357,23 @@ class PushSubscriptionPayload(BaseModel):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineReminderPayload(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
timezone: str = Field(min_length=1, max_length=64)
|
||||||
|
reminder_hour: int = Field(default=9, ge=0, le=23)
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
StepUpAction = Literal[
|
StepUpAction = Literal[
|
||||||
"merge_pull",
|
"merge_pull",
|
||||||
"submit_pull_review",
|
"submit_pull_review",
|
||||||
|
|
@ -1868,10 +1896,16 @@ async def push_status(request: Request):
|
||||||
_push_subscription_store.is_subscribed,
|
_push_subscription_store.is_subscribed,
|
||||||
device_id,
|
device_id,
|
||||||
)
|
)
|
||||||
|
preferences = await asyncio.to_thread(
|
||||||
|
_push_subscription_store.deadline_preferences, device_id
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"available": configuration.enabled,
|
"available": configuration.enabled,
|
||||||
"subscribed": subscribed,
|
"subscribed": subscribed,
|
||||||
"public_key": configuration.public_key if configuration.enabled else "",
|
"public_key": configuration.public_key if configuration.enabled else "",
|
||||||
|
"deadline_enabled": preferences["enabled"],
|
||||||
|
"timezone": preferences["timezone"],
|
||||||
|
"reminder_hour": preferences["reminder_hour"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1925,6 +1959,29 @@ async def unsubscribe_push(request: Request):
|
||||||
return {"subscribed": False}
|
return {"subscribed": False}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/push-subscription/deadlines")
|
||||||
|
async def update_deadline_reminders(payload: DeadlineReminderPayload, 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_deadline_preferences,
|
||||||
|
device_id,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
timezone=payload.timezone,
|
||||||
|
reminder_hour=payload.reminder_hour,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"deadline_enabled": payload.enabled,
|
||||||
|
"timezone": payload.timezone,
|
||||||
|
"reminder_hour": payload.reminder_hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/session/activity")
|
@app.post("/api/v1/session/activity")
|
||||||
async def record_session_activity(request: Request):
|
async def record_session_activity(request: Request):
|
||||||
session = request.state.dashboard_session
|
session = request.state.dashboard_session
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import json
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||||
|
|
@ -234,3 +236,97 @@ async def dispatch_unread_updates(
|
||||||
return sum(counts)
|
return sum(counts)
|
||||||
finally:
|
finally:
|
||||||
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_deadline_reminders(
|
||||||
|
store: PushSubscriptionStore,
|
||||||
|
configuration: PushConfiguration,
|
||||||
|
assigned: Callable[[], Awaitable[dict]],
|
||||||
|
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> int:
|
||||||
|
owner = secrets.token_urlsafe(18)
|
||||||
|
acquired = await asyncio.to_thread(
|
||||||
|
store.acquire_dispatch_lease,
|
||||||
|
owner,
|
||||||
|
now=time.time(),
|
||||||
|
lease_seconds=max(15.0, float(kwargs.get("send_timeout_seconds", 10.0)) + 5.0),
|
||||||
|
)
|
||||||
|
if not acquired:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return await _dispatch_deadline_reminders_unlocked(
|
||||||
|
store, configuration, assigned, send, **kwargs
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_deadline_reminders_unlocked(
|
||||||
|
store: PushSubscriptionStore,
|
||||||
|
configuration: PushConfiguration,
|
||||||
|
assigned: Callable[[], Awaitable[dict]],
|
||||||
|
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
session_active: Callable[[str], Awaitable[bool]] | None = None,
|
||||||
|
send_timeout_seconds: float = 10.0,
|
||||||
|
) -> int:
|
||||||
|
"""Send one privacy-safe Agenda digest per eligible device and local day."""
|
||||||
|
if not configuration.enabled:
|
||||||
|
return 0
|
||||||
|
devices = await asyncio.to_thread(store.deadline_reminder_devices)
|
||||||
|
if not devices:
|
||||||
|
return 0
|
||||||
|
snapshot = await assigned()
|
||||||
|
if snapshot.get("complete") is False:
|
||||||
|
return 0
|
||||||
|
current = now or datetime.now(timezone.utc)
|
||||||
|
due_cutoff = current + timedelta(hours=48)
|
||||||
|
due_count = 0
|
||||||
|
for item in snapshot.get("items", []):
|
||||||
|
if not isinstance(item, dict) or not item.get("due_date"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
due = datetime.fromisoformat(str(item["due_date"]).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if due.tzinfo is None:
|
||||||
|
due = due.replace(tzinfo=timezone.utc)
|
||||||
|
if due <= due_cutoff:
|
||||||
|
due_count += 1
|
||||||
|
if not due_count:
|
||||||
|
return 0
|
||||||
|
delivered = 0
|
||||||
|
for device in devices:
|
||||||
|
try:
|
||||||
|
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
continue
|
||||||
|
local_day = local_now.date().isoformat()
|
||||||
|
if local_now.hour < device.reminder_hour or device.delivered_local_day == local_day:
|
||||||
|
continue
|
||||||
|
if session_active is not None and not await session_active(device.session_id):
|
||||||
|
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||||
|
continue
|
||||||
|
payload = json.dumps({
|
||||||
|
"title": f"{due_count} deadline{'s' if due_count != 1 else ''} need{'s' if due_count == 1 else ''} attention",
|
||||||
|
"body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.",
|
||||||
|
"route": "#/my-work/agenda",
|
||||||
|
"tag": f"stackchain-deadline-digest-{local_day}",
|
||||||
|
"deadline_count": due_count,
|
||||||
|
}, separators=(",", ":"))
|
||||||
|
try:
|
||||||
|
operation = (
|
||||||
|
send(device.subscription, payload)
|
||||||
|
if send is not None
|
||||||
|
else send_web_push(device.subscription, payload, configuration)
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
await asyncio.to_thread(
|
||||||
|
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
||||||
|
)
|
||||||
|
delivered += 1
|
||||||
|
return delivered
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,15 @@ class PushDelivery:
|
||||||
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeadlineReminderDevice:
|
||||||
|
session_id: str
|
||||||
|
subscription: dict
|
||||||
|
timezone: str
|
||||||
|
reminder_hour: int
|
||||||
|
delivered_local_day: str | None
|
||||||
|
|
||||||
|
|
||||||
def _revisions(
|
def _revisions(
|
||||||
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||||
) -> tuple[tuple[int, str], ...]:
|
) -> tuple[tuple[int, str], ...]:
|
||||||
|
|
@ -78,6 +87,15 @@ 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_deadline_preferences (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||||
|
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||||
|
delivered_local_day TEXT,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
delivery_columns = {
|
delivery_columns = {
|
||||||
|
|
@ -154,6 +172,56 @@ class PushSubscriptionStore:
|
||||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||||
).fetchone() is not None
|
).fetchone() is not None
|
||||||
|
|
||||||
|
def set_deadline_preferences(
|
||||||
|
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
|
||||||
|
) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO push_deadline_preferences(
|
||||||
|
session_id, enabled, timezone, reminder_hour
|
||||||
|
) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
timezone = excluded.timezone,
|
||||||
|
reminder_hour = excluded.reminder_hour""",
|
||||||
|
(session_id, int(enabled), timezone, reminder_hour),
|
||||||
|
)
|
||||||
|
|
||||||
|
def deadline_preferences(self, session_id: str) -> dict:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""SELECT enabled, timezone, reminder_hour
|
||||||
|
FROM push_deadline_preferences WHERE session_id = ?""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"enabled": bool(row[0]) if row else False,
|
||||||
|
"timezone": row[1] if row else "UTC",
|
||||||
|
"reminder_hour": row[2] if row else 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||||
|
p.reminder_hour, p.delivered_local_day
|
||||||
|
FROM push_subscriptions s
|
||||||
|
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
||||||
|
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4])
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""UPDATE push_deadline_preferences SET delivered_local_day = ?
|
||||||
|
WHERE session_id = ? AND enabled = 1""",
|
||||||
|
(local_day, 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]]
|
||||||
) -> list[PushDelivery]:
|
) -> list[PushDelivery]:
|
||||||
|
|
|
||||||
162
tests/test_deadline_reminders.py
Normal file
162
tests/test_deadline_reminders.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import gitea_proxy
|
||||||
|
from src.push_notifications import PushConfiguration, dispatch_deadline_reminders
|
||||||
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplicates(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
"device-a", enabled=True, timezone="America/New_York", reminder_hour=9
|
||||||
|
)
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def assigned():
|
||||||
|
return {
|
||||||
|
"complete": True,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"title": "Private launch plan",
|
||||||
|
"repository": {"full_name": "private/repo"},
|
||||||
|
"due_date": "2026-08-14T12:00:00Z",
|
||||||
|
},
|
||||||
|
{"id": 43, "due_date": "2026-08-20T12:00:00Z"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send(_subscription, payload):
|
||||||
|
sent.append(json.loads(payload))
|
||||||
|
|
||||||
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||||
|
now = datetime(2026, 8, 13, 13, 5, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 1
|
||||||
|
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 0
|
||||||
|
assert sent == [{
|
||||||
|
"title": "1 deadline needs attention",
|
||||||
|
"body": "Open Agenda to review or replan it.",
|
||||||
|
"route": "#/my-work/agenda",
|
||||||
|
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||||
|
"deadline_count": 1,
|
||||||
|
}]
|
||||||
|
assert "Private launch plan" not in json.dumps(sent)
|
||||||
|
assert "private/repo" not in json.dumps(sent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
||||||
|
)
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def incomplete():
|
||||||
|
return {"complete": False, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||||
|
|
||||||
|
async def complete():
|
||||||
|
return {"complete": True, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||||
|
|
||||||
|
async def send(_subscription, payload):
|
||||||
|
sent.append(payload)
|
||||||
|
|
||||||
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||||
|
assert await dispatch_deadline_reminders(
|
||||||
|
store, config, incomplete, send,
|
||||||
|
now=datetime(2026, 8, 13, 18, 0, tzinfo=timezone.utc),
|
||||||
|
) == 0
|
||||||
|
assert await dispatch_deadline_reminders(
|
||||||
|
store, config, complete, send,
|
||||||
|
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||||
|
) == 0
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
"device-a", enabled=True, timezone="Europe/London", reminder_hour=8
|
||||||
|
)
|
||||||
|
|
||||||
|
assert store.deadline_preferences("device-a") == {
|
||||||
|
"enabled": True,
|
||||||
|
"timezone": "Europe/London",
|
||||||
|
"reminder_hour": 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
|
||||||
|
pages = {
|
||||||
|
1: {"items": [{"id": 1}], "has_more": True},
|
||||||
|
2: {"items": [{"id": 2}], "has_more": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def work_page(stream, page, limit):
|
||||||
|
assert stream == "issue"
|
||||||
|
assert limit == 50
|
||||||
|
return pages[page]
|
||||||
|
|
||||||
|
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||||
|
|
||||||
|
assert await gitea_proxy.assigned_issue_snapshot() == {
|
||||||
|
"items": [{"id": 1}, {"id": 2}],
|
||||||
|
"complete": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_competing_workers_send_one_deadline_digest(tmp_path):
|
||||||
|
path = tmp_path / "push.sqlite3"
|
||||||
|
first = PushSubscriptionStore(path)
|
||||||
|
second = PushSubscriptionStore(path)
|
||||||
|
first.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
first.set_deadline_preferences(
|
||||||
|
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
||||||
|
)
|
||||||
|
sending = __import__("asyncio").Event()
|
||||||
|
release = __import__("asyncio").Event()
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def assigned():
|
||||||
|
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||||
|
|
||||||
|
async def send(_subscription, payload):
|
||||||
|
sent.append(payload)
|
||||||
|
sending.set()
|
||||||
|
await release.wait()
|
||||||
|
|
||||||
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||||
|
now = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
|
||||||
|
active = __import__("asyncio").create_task(
|
||||||
|
dispatch_deadline_reminders(first, config, assigned, send, now=now)
|
||||||
|
)
|
||||||
|
await sending.wait()
|
||||||
|
competing = await dispatch_deadline_reminders(second, config, assigned, send, now=now)
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
assert competing == 0
|
||||||
|
assert await active == 1
|
||||||
|
assert len(sent) == 1
|
||||||
|
|
@ -14,6 +14,11 @@ const control = {
|
||||||
checked:false, disabled:false,
|
checked:false, disabled:false,
|
||||||
addEventListener:(_name, callback) => state.change = callback,
|
addEventListener:(_name, callback) => state.change = callback,
|
||||||
};
|
};
|
||||||
|
const deadlineControl = {
|
||||||
|
checked:false, disabled:false,
|
||||||
|
addEventListener:(_name, callback) => state.deadlineChange = callback,
|
||||||
|
};
|
||||||
|
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
||||||
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 existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||||
const registration = {pushManager:{
|
const registration = {pushManager:{
|
||||||
|
|
@ -21,7 +26,7 @@ const registration = {pushManager:{
|
||||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||||
}};
|
}};
|
||||||
const feature = createPushNotifications({
|
const feature = createPushNotifications({
|
||||||
control, status,
|
control, status, deadlineControl, deadlineStatus,
|
||||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||||
serviceWorker: {ready:Promise.resolve(registration)},
|
serviceWorker: {ready:Promise.resolve(registration)},
|
||||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||||
|
|
@ -57,6 +62,26 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["text"] == "New update notifications enabled for this device."
|
assert result["text"] == "New update notifications enabled for this device."
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt():
|
||||||
|
result = run_scenario("""
|
||||||
|
state.current = existing;
|
||||||
|
state.server = {available:true,subscribed:true,deadline_enabled:false,public_key:'AQID'};
|
||||||
|
await feature.init();
|
||||||
|
deadlineControl.checked = true;
|
||||||
|
await state.deadlineChange();
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["prompts"] == 0
|
||||||
|
assert result["subscriptions"] == []
|
||||||
|
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/deadlines", "PUT"]
|
||||||
|
body = json.loads(result["requests"][-1][2])
|
||||||
|
assert body["enabled"] is True
|
||||||
|
assert body["reminder_hour"] == 9
|
||||||
|
assert isinstance(body["timezone"], str) and body["timezone"]
|
||||||
|
assert result["deadlineText"] == "Deadline reminders enabled for 9:00 local time."
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||||
root = MODULE.parents[1]
|
root = MODULE.parents[1]
|
||||||
html = (root / "frontend" / "index.html").read_text()
|
html = (root / "frontend" / "index.html").read_text()
|
||||||
|
|
@ -68,6 +93,8 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||||
|
|
||||||
assert 'id="push-updates"' in html
|
assert 'id="push-updates"' in html
|
||||||
assert 'id="push-update-status"' in html
|
assert 'id="push-update-status"' in html
|
||||||
|
assert 'id="push-deadlines"' in html
|
||||||
|
assert 'id="push-deadline-status"' in html
|
||||||
assert '<script src="static/push-notifications.js"></script>' in html
|
assert '<script src="static/push-notifications.js"></script>' in html
|
||||||
assert "createPushNotifications({" in dashboard
|
assert "createPushNotifications({" in dashboard
|
||||||
assert "BASE + 'static/push-notifications.js'" in worker
|
assert "BASE + 'static/push-notifications.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -819,7 +819,12 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||||
)
|
)
|
||||||
|
|
||||||
assert await main.push_status(request) == {
|
assert await main.push_status(request) == {
|
||||||
"available": True, "subscribed": False, "public_key": "public-vapid"
|
"available": True,
|
||||||
|
"subscribed": False,
|
||||||
|
"public_key": "public-vapid",
|
||||||
|
"deadline_enabled": False,
|
||||||
|
"timezone": "UTC",
|
||||||
|
"reminder_hour": 9,
|
||||||
}
|
}
|
||||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||||
assert (await main.push_status(request))["subscribed"] is True
|
assert (await main.push_status(request))["subscribed"] is True
|
||||||
|
|
@ -827,6 +832,32 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||||
assert (await main.push_status(request))["subscribed"] is False
|
assert (await main.push_status(request))["subscribed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("session-a", {
|
||||||
|
"endpoint": "https://push.example/device-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.DeadlineReminderPayload(
|
||||||
|
enabled=True, timezone="America/New_York", reminder_hour=9
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await main.update_deadline_reminders(payload, request) == {
|
||||||
|
"deadline_enabled": True,
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"reminder_hour": 9,
|
||||||
|
}
|
||||||
|
assert store.deadline_preferences("session-a")["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
|
|
||||||
|
|
@ -459,6 +459,32 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
|
||||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_digest_push_opens_agenda_without_rendering_private_copy():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
await dispatchPush({
|
||||||
|
title:'must-not-render', body:'private details must-not-render',
|
||||||
|
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', deadline_count:3,
|
||||||
|
});
|
||||||
|
await dispatchNotificationClick('#/my-work/agenda');
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["notifications"] == [{
|
||||||
|
"title": "3 deadlines need attention",
|
||||||
|
"options": {
|
||||||
|
"body": "Open Agenda to review or replan them.",
|
||||||
|
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||||
|
"data": {"route": "#/my-work/agenda"},
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
assert result["opened"] == [
|
||||||
|
"https://forge.example/dashboard/#/my-work/agenda"
|
||||||
|
]
|
||||||
|
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||||
|
|
||||||
|
|
||||||
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user