From 0de8c83987126a25aa7516e797f2c7b7585d2790 Mon Sep 17 00:00:00 2001 From: Jack Levy Date: Sun, 1 Mar 2026 22:04:54 -0500 Subject: [PATCH] feat: weekly digest + local-time quiet hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weekly Digest (send_weekly_digest Celery task): - Runs every Monday 8:30 AM UTC via beat schedule - Queries all followed bills updated in the past 7 days per user - Sends low-priority ntfy push (Priority: low, Tags: newspaper,calendar) - Creates a NotificationEvent (weekly_digest type) for RSS feed visibility - Admin can trigger immediately via POST /api/admin/trigger-weekly-digest - Manual Controls panel now includes "Send Weekly Digest" button Local-time quiet hours: - Browser auto-detects IANA timezone via Intl.DateTimeFormat().resolvedOptions().timeZone - Timezone saved to notification_prefs alongside quiet_hours_start/end on Save - Dispatcher converts UTC → user's local time (zoneinfo stdlib) before hour comparison - Falls back to UTC if timezone absent or unrecognised - Quiet hours UI: 12-hour AM/PM selectors, shows detected timezone as hint - Clearing quiet hours also clears stored timezone Co-Authored-By: Jack Levy --- PocketVeto — Feature Roadmap.md | 2 +- backend/app/api/admin.py | 8 + backend/app/api/notifications.py | 5 + backend/app/schemas/schemas.py | 4 +- backend/app/workers/celery_app.py | 4 + .../app/workers/notification_dispatcher.py | 151 +++++++++++++++++- frontend/app/notifications/page.tsx | 82 ++++++---- frontend/app/settings/page.tsx | 7 + frontend/lib/api.ts | 2 + frontend/lib/types.ts | 1 + 10 files changed, 234 insertions(+), 32 deletions(-) diff --git a/PocketVeto — Feature Roadmap.md b/PocketVeto — Feature Roadmap.md index 50d43a7..76623a6 100644 --- a/PocketVeto — Feature Roadmap.md +++ b/PocketVeto — Feature Roadmap.md @@ -54,7 +54,7 @@ - [ ] **Collections / Watchlists** — `collections` (id, user_id, name, slug, is_public) + `collection_bills` join table. UI to create/manage collections and filter dashboard by collection. Shareable via public slug URL (read-only for non-owners). - [ ] **Personal Notes** — `bill_notes` table (user_id, bill_id, content, stance, tags, pinned). Shown on bill detail page. Private; optionally pin to top of the bill detail view. - [ ] **Shareable Links** — UUID token on briefs and collections → public read-only view, no login required. Same token system for both. No expiry by default. UUID (not sequential) to prevent enumeration. -- [ ] **Weekly Digest** — Celery beat task (weekly), queries followed bills for changes in the past 7 days, formats a low-noise summary, dispatches via ntfy + RSS. +- [x] **Weekly Digest** — Celery beat task (Monday 8:30 AM UTC), queries followed bills for changes in the past 7 days, formats a low-noise summary, dispatches via ntfy + RSS. Admin can trigger immediately from Manual Controls. --- diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 60c3608..7e7743f 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -238,6 +238,14 @@ async def resume_analysis(current_user: User = Depends(get_current_admin)): return {"task_id": task.id, "status": "queued"} +@router.post("/trigger-weekly-digest") +async def trigger_weekly_digest(current_user: User = Depends(get_current_admin)): + """Send the weekly bill activity summary to all eligible users now.""" + from app.workers.notification_dispatcher import send_weekly_digest + task = send_weekly_digest.delay() + return {"task_id": task.id, "status": "queued"} + + @router.post("/trigger-trend-scores") async def trigger_trend_scores(current_user: User = Depends(get_current_admin)): from app.workers.trend_scorer import calculate_all_trend_scores diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 1277595..0d3229a 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -31,6 +31,7 @@ _EVENT_LABELS = { "new_document": "New Bill Text", "new_amendment": "Amendment Filed", "bill_updated": "Bill Updated", + "weekly_digest": "Weekly Digest", } @@ -48,6 +49,7 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin digest_frequency=prefs.get("digest_frequency", "daily"), quiet_hours_start=prefs.get("quiet_hours_start"), quiet_hours_end=prefs.get("quiet_hours_end"), + timezone=prefs.get("timezone"), ) @@ -96,10 +98,13 @@ async def update_notification_settings( prefs["quiet_hours_start"] = body.quiet_hours_start if body.quiet_hours_end is not None: prefs["quiet_hours_end"] = body.quiet_hours_end + if body.timezone is not None: + prefs["timezone"] = body.timezone # Allow clearing quiet hours by passing -1 if body.quiet_hours_start == -1: prefs.pop("quiet_hours_start", None) prefs.pop("quiet_hours_end", None) + prefs.pop("timezone", None) user.notification_prefs = prefs diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 3e71c6e..613963b 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -18,9 +18,10 @@ class NotificationSettingsResponse(BaseModel): # Digest digest_enabled: bool = False digest_frequency: str = "daily" # daily | weekly - # Quiet hours (UTC hour integers 0-23, None = disabled) + # Quiet hours — stored as local-time hour integers (0-23); timezone is IANA name quiet_hours_start: Optional[int] = None quiet_hours_end: Optional[int] = None + timezone: Optional[str] = None # IANA name, e.g. "America/New_York" model_config = {"from_attributes": True} @@ -37,6 +38,7 @@ class NotificationSettingsUpdate(BaseModel): digest_frequency: Optional[str] = None quiet_hours_start: Optional[int] = None quiet_hours_end: Optional[int] = None + timezone: Optional[str] = None # IANA name sent by the browser on save class NotificationEventSchema(BaseModel): diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index acf108a..8f53570 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -82,5 +82,9 @@ celery_app.conf.update( "task": "app.workers.notification_dispatcher.send_notification_digest", "schedule": crontab(hour=8, minute=0), # 8 AM UTC daily }, + "send-weekly-digest": { + "task": "app.workers.notification_dispatcher.send_weekly_digest", + "schedule": crontab(hour=8, minute=30, day_of_week=1), # Monday 8:30 AM UTC + }, }, ) diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py index 62805e6..a143992 100644 --- a/backend/app/workers/notification_dispatcher.py +++ b/backend/app/workers/notification_dispatcher.py @@ -28,6 +28,7 @@ _EVENT_TITLES = { "new_document": "New Bill Text", "new_amendment": "Amendment Filed", "bill_updated": "Bill Updated", + "weekly_digest": "Weekly Digest", } _EVENT_TAGS = { @@ -45,12 +46,27 @@ _EVENT_PRIORITY = { def _in_quiet_hours(prefs: dict, now: datetime) -> bool: - """Return True if the current UTC hour falls within the user's quiet window.""" + """Return True if the current local time falls within the user's quiet window. + + Quiet hours are stored as local-time hour integers. If the user has a stored + IANA timezone name we convert ``now`` (UTC) to that zone before comparing. + Falls back to UTC if the timezone is absent or unrecognised. + """ start = prefs.get("quiet_hours_start") end = prefs.get("quiet_hours_end") if start is None or end is None: return False - h = now.hour + + tz_name = prefs.get("timezone") + if tz_name: + try: + from zoneinfo import ZoneInfo + h = now.astimezone(ZoneInfo(tz_name)).hour + except Exception: + h = now.hour # unrecognised timezone — degrade gracefully to UTC + else: + h = now.hour + if start <= end: return start <= h < end # Wraps midnight (e.g. 22 → 8) @@ -250,6 +266,137 @@ def _send_ntfy( resp.raise_for_status() +@celery_app.task(bind=True, name="app.workers.notification_dispatcher.send_weekly_digest") +def send_weekly_digest(self): + """ + Proactive week-in-review summary for followed bills. + + Runs every Monday at 8:30 AM UTC. Queries bills followed by each user + for any activity in the past 7 days and dispatches a low-noise summary + via ntfy and/or creates a NotificationEvent for the RSS feed. + + Unlike send_notification_digest (which bundles queued events), this task + generates a fresh summary independent of the notification event queue. + """ + from app.config import settings as app_settings + from app.models.bill import Bill + + db = get_sync_db() + try: + now = datetime.now(timezone.utc) + cutoff = now - timedelta(days=7) + base_url = (app_settings.PUBLIC_URL or app_settings.LOCAL_URL).rstrip("/") + + users = db.query(User).all() + ntfy_sent = 0 + rss_created = 0 + + for user in users: + prefs = user.notification_prefs or {} + ntfy_enabled = prefs.get("ntfy_enabled", False) + ntfy_url = prefs.get("ntfy_topic_url", "").strip() + rss_enabled = prefs.get("rss_enabled", False) + ntfy_configured = ntfy_enabled and bool(ntfy_url) + + if not ntfy_configured and not rss_enabled: + continue + + bill_follows = db.query(Follow).filter_by( + user_id=user.id, follow_type="bill" + ).all() + if not bill_follows: + continue + + bill_ids = [f.follow_value for f in bill_follows] + + active_bills = ( + db.query(Bill) + .filter( + Bill.bill_id.in_(bill_ids), + Bill.updated_at >= cutoff, + ) + .order_by(Bill.updated_at.desc()) + .limit(20) + .all() + ) + + if not active_bills: + continue + + count = len(active_bills) + anchor = active_bills[0] + + summary_lines = [] + for bill in active_bills[:10]: + lbl = _format_bill_label(bill) + action = (bill.latest_action_text or "")[:80] + summary_lines.append(f"• {lbl}: {action}" if action else f"• {lbl}") + if count > 10: + summary_lines.append(f" …and {count - 10} more") + summary = "\n".join(summary_lines) + + # Mark dispatched_at immediately so dispatch_notifications skips this event; + # it still appears in the RSS feed since that endpoint reads all events. + event = NotificationEvent( + user_id=user.id, + bill_id=anchor.bill_id, + event_type="weekly_digest", + dispatched_at=now, + payload={ + "bill_label": "Weekly Digest", + "bill_title": f"{count} followed bill{'s' if count != 1 else ''} had activity this week", + "brief_summary": summary, + "bill_count": count, + "bill_url": f"{base_url}/bills/{anchor.bill_id}", + }, + ) + db.add(event) + rss_created += 1 + + if ntfy_configured: + try: + _send_weekly_digest_ntfy(count, summary, ntfy_url, prefs) + ntfy_sent += 1 + except Exception as e: + logger.warning(f"Weekly digest ntfy failed for user {user.id}: {e}") + + db.commit() + logger.info(f"send_weekly_digest: {ntfy_sent} ntfy sent, {rss_created} events created") + return {"ntfy_sent": ntfy_sent, "rss_created": rss_created} + finally: + db.close() + + +def _format_bill_label(bill) -> str: + _TYPE_MAP = { + "hr": "H.R.", "s": "S.", "hjres": "H.J.Res.", "sjres": "S.J.Res.", + "hconres": "H.Con.Res.", "sconres": "S.Con.Res.", "hres": "H.Res.", "sres": "S.Res.", + } + prefix = _TYPE_MAP.get(bill.bill_type.lower(), bill.bill_type.upper()) + return f"{prefix} {bill.bill_number}" + + +def _send_weekly_digest_ntfy(count: int, summary: str, ntfy_url: str, prefs: dict) -> None: + auth_method = prefs.get("ntfy_auth_method", "none") + ntfy_token = prefs.get("ntfy_token", "").strip() + ntfy_username = prefs.get("ntfy_username", "").strip() + ntfy_password = prefs.get("ntfy_password", "").strip() + + headers = { + "Title": f"PocketVeto Weekly — {count} bill{'s' if count != 1 else ''} updated", + "Priority": "low", + "Tags": "newspaper,calendar", + } + if auth_method == "token" and ntfy_token: + headers["Authorization"] = f"Bearer {ntfy_token}" + elif auth_method == "basic" and ntfy_username: + creds = base64.b64encode(f"{ntfy_username}:{ntfy_password}".encode()).decode() + headers["Authorization"] = f"Basic {creds}" + + resp = requests.post(ntfy_url, data=summary.encode("utf-8"), headers=headers, timeout=NTFY_TIMEOUT) + resp.raise_for_status() + + def _send_digest_ntfy(events: list, ntfy_url: str, prefs: dict) -> None: auth_method = prefs.get("ntfy_auth_method", "none") ntfy_token = prefs.get("ntfy_token", "").strip() diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx index d21f3d1..8087eb2 100644 --- a/frontend/app/notifications/page.tsx +++ b/frontend/app/notifications/page.tsx @@ -16,10 +16,11 @@ const AUTH_METHODS = [ { value: "basic", label: "Username & password", hint: "For servers behind HTTP basic auth or nginx ACL" }, ]; -const HOURS = Array.from({ length: 24 }, (_, i) => ({ - value: i, - label: `${i.toString().padStart(2, "0")}:00 UTC`, -})); +const HOURS = Array.from({ length: 24 }, (_, i) => { + const period = i < 12 ? "AM" : "PM"; + const hour = i === 0 ? 12 : i > 12 ? i - 12 : i; + return { value: i, label: `${hour}:00 ${period}` }; +}); const EVENT_META: Record = { new_document: { label: "New Bill Text", icon: FileText, color: "text-blue-500" }, @@ -83,12 +84,23 @@ export default function NotificationsPage() { const [quietStart, setQuietStart] = useState(22); const [quietEnd, setQuietEnd] = useState(8); const [quietSaved, setQuietSaved] = useState(false); + const [detectedTimezone, setDetectedTimezone] = useState(""); + const [savedTimezone, setSavedTimezone] = useState(null); // Digest state const [digestEnabled, setDigestEnabled] = useState(false); const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily"); const [digestSaved, setDigestSaved] = useState(false); + // Detect the browser's local timezone once on mount + useEffect(() => { + try { + setDetectedTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); + } catch { + // very old browser — leave empty, dispatcher will fall back to UTC + } + }, []); + // Populate from loaded settings useEffect(() => { if (!settings) return; @@ -99,6 +111,7 @@ export default function NotificationsPage() { setPassword(settings.ntfy_password ?? ""); setDigestEnabled(settings.digest_enabled ?? false); setDigestFrequency(settings.digest_frequency ?? "daily"); + setSavedTimezone(settings.timezone ?? null); if (settings.quiet_hours_start != null) { setQuietEnabled(true); setQuietStart(settings.quiet_hours_start); @@ -130,17 +143,20 @@ export default function NotificationsPage() { }; const saveQuietHours = () => { + const onSuccess = () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); }; if (quietEnabled) { update.mutate( - { quiet_hours_start: quietStart, quiet_hours_end: quietEnd }, - { onSuccess: () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); } } + { + quiet_hours_start: quietStart, + quiet_hours_end: quietEnd, + // Include the detected timezone so the dispatcher knows which local time to compare + ...(detectedTimezone ? { timezone: detectedTimezone } : {}), + }, + { onSuccess } ); } else { - // -1 signals the backend to clear the values - update.mutate( - { quiet_hours_start: -1 }, - { onSuccess: () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); } } - ); + // -1 signals the backend to clear quiet hours + timezone + update.mutate({ quiet_hours_start: -1 }, { onSuccess }); } }; @@ -324,7 +340,7 @@ export default function NotificationsPage() {

Pause ntfy push notifications during set hours. Events accumulate and fire as a batch when quiet hours end. - All times are UTC. RSS is unaffected. + RSS is unaffected.

@@ -335,23 +351,33 @@ export default function NotificationsPage() { {quietEnabled && ( -
-
- - +
+
+
+ + +
+
+ + +
+ {quietStart > quietEnd && ( + (overnight window) + )}
-
- - -
- {quietStart > quietEnd && ( - (overnight window) + {detectedTimezone && ( +

+ Times are in your local timezone: {detectedTimezone} + {savedTimezone && savedTimezone !== detectedTimezone && ( + · saved as {savedTimezone} — save to update + )} +

)}
)} diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index 57776da..c117496 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -734,6 +734,13 @@ export default function SettingsPage() { count: stats?.pending_llm, countLabel: "bills pending analysis", }, + { + key: "weekly-digest", + name: "Send Weekly Digest", + description: "Immediately dispatch the weekly bill activity summary to all users who have ntfy or RSS enabled and at least one bill followed. Runs automatically every Monday at 8:30 AM UTC.", + fn: adminAPI.triggerWeeklyDigest, + status: "on-demand", + }, ]; const maintenance: ControlItem[] = [ diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index bc3fb5c..ca20388 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -241,6 +241,8 @@ export const adminAPI = { apiClient.post("/api/admin/backfill-labels").then((r) => r.data), resumeAnalysis: () => apiClient.post("/api/admin/resume-analysis").then((r) => r.data), + triggerWeeklyDigest: () => + apiClient.post("/api/admin/trigger-weekly-digest").then((r) => r.data), getApiHealth: () => apiClient.get("/api/admin/api-health").then((r) => r.data), getTaskStatus: (taskId: string) => diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 85de21b..a568e53 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -171,6 +171,7 @@ export interface NotificationSettings { digest_frequency: "daily" | "weekly"; quiet_hours_start: number | null; quiet_hours_end: number | null; + timezone: string | null; // IANA name, e.g. "America/New_York" } export interface NotificationEvent {