feat: weekly digest + local-time quiet hours

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
This commit is contained in:
Jack Levy
2026-03-01 22:04:54 -05:00
parent a0e7ab4cd3
commit 0de8c83987
10 changed files with 234 additions and 32 deletions

View File

@@ -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). - [ ] **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. - [ ] **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. - [ ] **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.
--- ---

View File

@@ -238,6 +238,14 @@ async def resume_analysis(current_user: User = Depends(get_current_admin)):
return {"task_id": task.id, "status": "queued"} 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") @router.post("/trigger-trend-scores")
async def trigger_trend_scores(current_user: User = Depends(get_current_admin)): async def trigger_trend_scores(current_user: User = Depends(get_current_admin)):
from app.workers.trend_scorer import calculate_all_trend_scores from app.workers.trend_scorer import calculate_all_trend_scores

View File

@@ -31,6 +31,7 @@ _EVENT_LABELS = {
"new_document": "New Bill Text", "new_document": "New Bill Text",
"new_amendment": "Amendment Filed", "new_amendment": "Amendment Filed",
"bill_updated": "Bill Updated", "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"), digest_frequency=prefs.get("digest_frequency", "daily"),
quiet_hours_start=prefs.get("quiet_hours_start"), quiet_hours_start=prefs.get("quiet_hours_start"),
quiet_hours_end=prefs.get("quiet_hours_end"), 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 prefs["quiet_hours_start"] = body.quiet_hours_start
if body.quiet_hours_end is not None: if body.quiet_hours_end is not None:
prefs["quiet_hours_end"] = body.quiet_hours_end 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 # Allow clearing quiet hours by passing -1
if body.quiet_hours_start == -1: if body.quiet_hours_start == -1:
prefs.pop("quiet_hours_start", None) prefs.pop("quiet_hours_start", None)
prefs.pop("quiet_hours_end", None) prefs.pop("quiet_hours_end", None)
prefs.pop("timezone", None)
user.notification_prefs = prefs user.notification_prefs = prefs

View File

@@ -18,9 +18,10 @@ class NotificationSettingsResponse(BaseModel):
# Digest # Digest
digest_enabled: bool = False digest_enabled: bool = False
digest_frequency: str = "daily" # daily | weekly 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_start: Optional[int] = None
quiet_hours_end: 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} model_config = {"from_attributes": True}
@@ -37,6 +38,7 @@ class NotificationSettingsUpdate(BaseModel):
digest_frequency: Optional[str] = None digest_frequency: Optional[str] = None
quiet_hours_start: Optional[int] = None quiet_hours_start: Optional[int] = None
quiet_hours_end: Optional[int] = None quiet_hours_end: Optional[int] = None
timezone: Optional[str] = None # IANA name sent by the browser on save
class NotificationEventSchema(BaseModel): class NotificationEventSchema(BaseModel):

View File

@@ -82,5 +82,9 @@ celery_app.conf.update(
"task": "app.workers.notification_dispatcher.send_notification_digest", "task": "app.workers.notification_dispatcher.send_notification_digest",
"schedule": crontab(hour=8, minute=0), # 8 AM UTC daily "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
},
}, },
) )

View File

@@ -28,6 +28,7 @@ _EVENT_TITLES = {
"new_document": "New Bill Text", "new_document": "New Bill Text",
"new_amendment": "Amendment Filed", "new_amendment": "Amendment Filed",
"bill_updated": "Bill Updated", "bill_updated": "Bill Updated",
"weekly_digest": "Weekly Digest",
} }
_EVENT_TAGS = { _EVENT_TAGS = {
@@ -45,12 +46,27 @@ _EVENT_PRIORITY = {
def _in_quiet_hours(prefs: dict, now: datetime) -> bool: 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") start = prefs.get("quiet_hours_start")
end = prefs.get("quiet_hours_end") end = prefs.get("quiet_hours_end")
if start is None or end is None: if start is None or end is None:
return False 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: if start <= end:
return start <= h < end return start <= h < end
# Wraps midnight (e.g. 22 → 8) # Wraps midnight (e.g. 22 → 8)
@@ -250,6 +266,137 @@ def _send_ntfy(
resp.raise_for_status() 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: def _send_digest_ntfy(events: list, ntfy_url: str, prefs: dict) -> None:
auth_method = prefs.get("ntfy_auth_method", "none") auth_method = prefs.get("ntfy_auth_method", "none")
ntfy_token = prefs.get("ntfy_token", "").strip() ntfy_token = prefs.get("ntfy_token", "").strip()

View File

@@ -16,10 +16,11 @@ const AUTH_METHODS = [
{ value: "basic", label: "Username & password", hint: "For servers behind HTTP basic auth or nginx ACL" }, { value: "basic", label: "Username & password", hint: "For servers behind HTTP basic auth or nginx ACL" },
]; ];
const HOURS = Array.from({ length: 24 }, (_, i) => ({ const HOURS = Array.from({ length: 24 }, (_, i) => {
value: i, const period = i < 12 ? "AM" : "PM";
label: `${i.toString().padStart(2, "0")}:00 UTC`, const hour = i === 0 ? 12 : i > 12 ? i - 12 : i;
})); return { value: i, label: `${hour}:00 ${period}` };
});
const EVENT_META: Record<string, { label: string; icon: typeof Bell; color: string }> = { const EVENT_META: Record<string, { label: string; icon: typeof Bell; color: string }> = {
new_document: { label: "New Bill Text", icon: FileText, color: "text-blue-500" }, 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 [quietStart, setQuietStart] = useState(22);
const [quietEnd, setQuietEnd] = useState(8); const [quietEnd, setQuietEnd] = useState(8);
const [quietSaved, setQuietSaved] = useState(false); const [quietSaved, setQuietSaved] = useState(false);
const [detectedTimezone, setDetectedTimezone] = useState<string>("");
const [savedTimezone, setSavedTimezone] = useState<string | null>(null);
// Digest state // Digest state
const [digestEnabled, setDigestEnabled] = useState(false); const [digestEnabled, setDigestEnabled] = useState(false);
const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily"); const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily");
const [digestSaved, setDigestSaved] = useState(false); 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 // Populate from loaded settings
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings) return;
@@ -99,6 +111,7 @@ export default function NotificationsPage() {
setPassword(settings.ntfy_password ?? ""); setPassword(settings.ntfy_password ?? "");
setDigestEnabled(settings.digest_enabled ?? false); setDigestEnabled(settings.digest_enabled ?? false);
setDigestFrequency(settings.digest_frequency ?? "daily"); setDigestFrequency(settings.digest_frequency ?? "daily");
setSavedTimezone(settings.timezone ?? null);
if (settings.quiet_hours_start != null) { if (settings.quiet_hours_start != null) {
setQuietEnabled(true); setQuietEnabled(true);
setQuietStart(settings.quiet_hours_start); setQuietStart(settings.quiet_hours_start);
@@ -130,17 +143,20 @@ export default function NotificationsPage() {
}; };
const saveQuietHours = () => { const saveQuietHours = () => {
const onSuccess = () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); };
if (quietEnabled) { if (quietEnabled) {
update.mutate( 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 { } else {
// -1 signals the backend to clear the values // -1 signals the backend to clear quiet hours + timezone
update.mutate( update.mutate({ quiet_hours_start: -1 }, { onSuccess });
{ quiet_hours_start: -1 },
{ onSuccess: () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); } }
);
} }
}; };
@@ -324,7 +340,7 @@ export default function NotificationsPage() {
</h2> </h2>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Pause ntfy push notifications during set hours. Events accumulate and fire as a batch when quiet hours end. 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.
</p> </p>
</div> </div>
@@ -335,23 +351,33 @@ export default function NotificationsPage() {
</label> </label>
{quietEnabled && ( {quietEnabled && (
<div className="flex items-center gap-3 flex-wrap"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3 flex-wrap">
<label className="text-sm text-muted-foreground">From</label> <div className="flex items-center gap-2">
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))} <label className="text-sm text-muted-foreground">From</label>
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md"> <select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)} className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
</select> {HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">To</label>
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
{quietStart > quietEnd && (
<span className="text-xs text-muted-foreground">(overnight window)</span>
)}
</div> </div>
<div className="flex items-center gap-2"> {detectedTimezone && (
<label className="text-sm text-muted-foreground">To</label> <p className="text-xs text-muted-foreground">
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))} Times are in your local timezone: <span className="font-medium text-foreground">{detectedTimezone}</span>
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md"> {savedTimezone && savedTimezone !== detectedTimezone && (
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)} <span className="text-amber-600 dark:text-amber-400"> · saved as {savedTimezone} save to update</span>
</select> )}
</div> </p>
{quietStart > quietEnd && (
<span className="text-xs text-muted-foreground">(overnight window)</span>
)} )}
</div> </div>
)} )}

View File

@@ -734,6 +734,13 @@ export default function SettingsPage() {
count: stats?.pending_llm, count: stats?.pending_llm,
countLabel: "bills pending analysis", 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[] = [ const maintenance: ControlItem[] = [

View File

@@ -241,6 +241,8 @@ export const adminAPI = {
apiClient.post("/api/admin/backfill-labels").then((r) => r.data), apiClient.post("/api/admin/backfill-labels").then((r) => r.data),
resumeAnalysis: () => resumeAnalysis: () =>
apiClient.post("/api/admin/resume-analysis").then((r) => r.data), apiClient.post("/api/admin/resume-analysis").then((r) => r.data),
triggerWeeklyDigest: () =>
apiClient.post("/api/admin/trigger-weekly-digest").then((r) => r.data),
getApiHealth: () => getApiHealth: () =>
apiClient.get<ApiHealth>("/api/admin/api-health").then((r) => r.data), apiClient.get<ApiHealth>("/api/admin/api-health").then((r) => r.data),
getTaskStatus: (taskId: string) => getTaskStatus: (taskId: string) =>

View File

@@ -171,6 +171,7 @@ export interface NotificationSettings {
digest_frequency: "daily" | "weekly"; digest_frequency: "daily" | "weekly";
quiet_hours_start: number | null; quiet_hours_start: number | null;
quiet_hours_end: number | null; quiet_hours_end: number | null;
timezone: string | null; // IANA name, e.g. "America/New_York"
} }
export interface NotificationEvent { export interface NotificationEvent {