Merge feat/weekly-digest: weekly digest + local-time quiet hours

Co-Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 22:05:17 -05:00
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).
- [ ] **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.
---

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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
},
},
)

View File

@@ -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()

View File

@@ -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<string, { label: string; icon: typeof Bell; color: string }> = {
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<string>("");
const [savedTimezone, setSavedTimezone] = useState<string | null>(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() {
</h2>
<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.
All times are UTC. RSS is unaffected.
RSS is unaffected.
</p>
</div>
@@ -335,23 +351,33 @@ export default function NotificationsPage() {
</label>
{quietEnabled && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">From</label>
<select value={quietStart} onChange={(e) => setQuietStart(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 className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">From</label>
<select value={quietStart} onChange={(e) => setQuietStart(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>
<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 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>
{detectedTimezone && (
<p className="text-xs text-muted-foreground">
Times are in your local timezone: <span className="font-medium text-foreground">{detectedTimezone}</span>
{savedTimezone && savedTimezone !== detectedTimezone && (
<span className="text-amber-600 dark:text-amber-400"> · saved as {savedTimezone} save to update</span>
)}
</p>
)}
</div>
)}

View File

@@ -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[] = [

View File

@@ -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<ApiHealth>("/api/admin/api-health").then((r) => r.data),
getTaskStatus: (taskId: string) =>

View File

@@ -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 {