feat: send mobile deadline reminders (Closes #709)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-13 05:00:32 +00:00
1 parent 4209834d6d
commit 5e6328a8d0
12 files changed
+540 -5

No files matched your search

+11
View File
@@ -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:
if not isinstance(item, dict):
return None
+58 -1
View File
@@ -48,7 +48,11 @@ from src.live_snapshot_store import (
)
from src.models import Issue, Milestone, PullRequest, Repo, User
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_subscription_store import PushSubscriptionStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
@@ -112,6 +116,13 @@ async def _push_poll_loop() -> None:
max_concurrency=max_concurrency,
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:
raise
except Exception:
@@ -346,6 +357,23 @@ class PushSubscriptionPayload(BaseModel):
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[
"merge_pull",
"submit_pull_review",
@@ -1868,10 +1896,16 @@ async def push_status(request: Request):
_push_subscription_store.is_subscribed,
device_id,
)
preferences = await asyncio.to_thread(
_push_subscription_store.deadline_preferences, device_id
)
return {
"available": configuration.enabled,
"subscribed": subscribed,
"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}
@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")
async def record_session_activity(request: Request):
session = request.state.dashboard_session
+96
View File
@@ -3,7 +3,9 @@ import json
import secrets
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
@@ -234,3 +236,97 @@ async def dispatch_unread_updates(
return sum(counts)
finally:
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
+68
View File
@@ -22,6 +22,15 @@ class PushDelivery:
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(
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> tuple[tuple[int, str], ...]:
@@ -78,6 +87,15 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
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 = {
@@ -154,6 +172,56 @@ class PushSubscriptionStore:
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
).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(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]: