From a39ae4ccbaabe26fd37b96758972cba5429a7652 Mon Sep 17 00:00:00 2001 From: Jack Levy Date: Mon, 2 Mar 2026 19:05:24 -0500 Subject: [PATCH] feat: granular per-mode alert filters (v0.9.3) Replace coarse milestone/referral suppression with 8 named action categories (vote, presidential, committee_report, calendar, procedural, referral, new_document, new_amendment), each independently togglable per follow mode (Follow / Pocket Veto / Pocket Boost). - notification_utils: categorize_action() replaces is_milestone_action / is_referral_action; _build_payload stores action_category in payload - congress_poller: use categorize_action() in _update_bill_if_changed - notification_dispatcher: _should_dispatch() checks per-mode filter dict from notification_prefs; follow mode looked up before filter check - schemas + api: alert_filters (nested dict) wired through settings GET/PUT endpoints; no DB migration required - frontend: tabbed Alert Filters section (Follow / Pocket Veto / Pocket Boost), each with independent 8-toggle filter set, milestone parent checkbox (indeterminate-aware), Load defaults button, and per-tab Save button Authored-By: Jack Levy --- backend/app/api/notifications.py | 3 + backend/app/schemas/schemas.py | 2 + backend/app/workers/congress_poller.py | 15 +- .../app/workers/notification_dispatcher.py | 47 +++-- backend/app/workers/notification_utils.py | 56 +++-- frontend/app/notifications/page.tsx | 196 +++++++++++++++++- frontend/lib/types.ts | 1 + 7 files changed, 262 insertions(+), 58 deletions(-) diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 0d3229a..bf25a55 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -50,6 +50,7 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin quiet_hours_start=prefs.get("quiet_hours_start"), quiet_hours_end=prefs.get("quiet_hours_end"), timezone=prefs.get("timezone"), + alert_filters=prefs.get("alert_filters"), ) @@ -100,6 +101,8 @@ async def update_notification_settings( prefs["quiet_hours_end"] = body.quiet_hours_end if body.timezone is not None: prefs["timezone"] = body.timezone + if body.alert_filters is not None: + prefs["alert_filters"] = body.alert_filters # Allow clearing quiet hours by passing -1 if body.quiet_hours_start == -1: prefs.pop("quiet_hours_start", None) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 4995540..89cf270 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -42,6 +42,7 @@ class NotificationSettingsResponse(BaseModel): quiet_hours_start: Optional[int] = None quiet_hours_end: Optional[int] = None timezone: Optional[str] = None # IANA name, e.g. "America/New_York" + alert_filters: Optional[dict] = None model_config = {"from_attributes": True} @@ -59,6 +60,7 @@ class NotificationSettingsUpdate(BaseModel): quiet_hours_start: Optional[int] = None quiet_hours_end: Optional[int] = None timezone: Optional[str] = None # IANA name sent by the browser on save + alert_filters: Optional[dict] = None class NotificationEventSchema(BaseModel): diff --git a/backend/app/workers/congress_poller.py b/backend/app/workers/congress_poller.py index cf3be96..32139c7 100644 --- a/backend/app/workers/congress_poller.py +++ b/backend/app/workers/congress_poller.py @@ -339,16 +339,13 @@ def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool: emit_bill_notification, emit_member_follow_notifications, emit_topic_follow_notifications, - is_milestone_action, - is_referral_action, + categorize_action, ) action_text = parsed.get("latest_action_text", "") - is_milestone = is_milestone_action(action_text) - is_referral = not is_milestone and is_referral_action(action_text) - if is_milestone or is_referral: - tier = "progress" if is_milestone else "referral" - emit_bill_notification(db, existing, "bill_updated", action_text, milestone_tier=tier) - emit_member_follow_notifications(db, existing, "bill_updated", action_text, milestone_tier=tier) + action_category = categorize_action(action_text) + if action_category: + emit_bill_notification(db, existing, "bill_updated", action_text, action_category=action_category) + emit_member_follow_notifications(db, existing, "bill_updated", action_text, action_category=action_category) # Topic followers — pull tags from the bill's latest brief from app.models.brief import BillBrief latest_brief = ( @@ -359,7 +356,7 @@ def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool: ) topic_tags = latest_brief.topic_tags or [] if latest_brief else [] emit_topic_follow_notifications( - db, existing, "bill_updated", action_text, topic_tags, milestone_tier=tier + db, existing, "bill_updated", action_text, topic_tags, action_category=action_category ) return changed diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py index f3775c5..99054b1 100644 --- a/backend/app/workers/notification_dispatcher.py +++ b/backend/app/workers/notification_dispatcher.py @@ -45,6 +45,32 @@ _EVENT_PRIORITY = { } +_FILTER_DEFAULTS = { + "new_document": False, "new_amendment": False, "vote": False, + "presidential": False, "committee_report": False, "calendar": False, + "procedural": False, "referral": False, +} + + +def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool: + payload = event.payload or {} + + # Map event type directly for document events + if event.event_type == "new_document": + key = "new_document" + elif event.event_type == "new_amendment": + key = "new_amendment" + else: + # Use action_category if present (new events), fall back from milestone_tier (old events) + key = payload.get("action_category") + if not key: + key = "referral" if payload.get("milestone_tier") == "referral" else "vote" + + all_filters = prefs.get("alert_filters") or {} + mode_filters = all_filters.get(follow_mode) or {} + return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True))) + + def _in_quiet_hours(prefs: dict, now: datetime) -> bool: """Return True if the current local time falls within the user's quiet window. @@ -104,23 +130,12 @@ def dispatch_notifications(self): ).first() follow_mode = follow.follow_mode if follow else "neutral" - # Pocket Veto: only milestone (bill_updated) events; skip LLM brief events - if follow_mode == "pocket_veto" and event.event_type in ("new_document", "new_amendment"): - event.dispatched_at = now - db.commit() - continue - - # Referral-tier events (committee referrals) are noisy for loose topic follows; - # suppress them only for topic-follow events so direct bill/member followers - # still get notified when their bill is referred to committee. - payload = event.payload or {} - is_topic_follow = payload.get("source") == "topic_follow" - if follow_mode == "neutral" and payload.get("milestone_tier") == "referral" and is_topic_follow: - event.dispatched_at = now - db.commit() - continue - prefs = user.notification_prefs or {} + + if not _should_dispatch(event, prefs, follow_mode): + event.dispatched_at = now + db.commit() + continue ntfy_url = prefs.get("ntfy_topic_url", "").strip() ntfy_auth_method = prefs.get("ntfy_auth_method", "none") ntfy_token = prefs.get("ntfy_token", "").strip() diff --git a/backend/app/workers/notification_utils.py b/backend/app/workers/notification_utils.py index ea1fe6d..439678e 100644 --- a/backend/app/workers/notification_utils.py +++ b/backend/app/workers/notification_utils.py @@ -4,39 +4,31 @@ Centralised here to avoid circular imports. """ from datetime import datetime, timedelta, timezone -_MILESTONE_KEYWORDS = [ - "passed", "failed", "agreed to", - "signed", "vetoed", "enacted", - "presented to the president", - "ordered to be reported", "ordered reported", - "reported by", "discharged", - "placed on", # placed on calendar - "cloture", "roll call", - "markup", # markup session scheduled/completed - "conference", # conference committee activity -] - -# Committee referral — meaningful for pocket_veto/boost but noisy for neutral -_REFERRAL_KEYWORDS = [ - "referred to", -] +_VOTE_KW = ["passed", "failed", "agreed to", "roll call"] +_PRES_KW = ["signed", "vetoed", "enacted", "presented to the president"] +_COMMITTEE_KW = ["markup", "ordered to be reported", "ordered reported", "reported by", "discharged"] +_CALENDAR_KW = ["placed on"] +_PROCEDURAL_KW = ["cloture", "conference"] +_REFERRAL_KW = ["referred to"] # Events created within this window for the same (user, bill, event_type) are suppressed _DEDUP_MINUTES = 30 -def is_milestone_action(action_text: str) -> bool: +def categorize_action(action_text: str) -> str | None: + """Return the action category string, or None if not notification-worthy.""" t = (action_text or "").lower() - return any(kw in t for kw in _MILESTONE_KEYWORDS) - - -def is_referral_action(action_text: str) -> bool: - t = (action_text or "").lower() - return any(kw in t for kw in _REFERRAL_KEYWORDS) + if any(kw in t for kw in _VOTE_KW): return "vote" + if any(kw in t for kw in _PRES_KW): return "presidential" + if any(kw in t for kw in _COMMITTEE_KW): return "committee_report" + if any(kw in t for kw in _CALENDAR_KW): return "calendar" + if any(kw in t for kw in _PROCEDURAL_KW): return "procedural" + if any(kw in t for kw in _REFERRAL_KW): return "referral" + return None def _build_payload( - bill, action_summary: str, milestone_tier: str = "progress", source: str = "bill_follow" + bill, action_summary: str, action_category: str, source: str = "bill_follow" ) -> dict: from app.config import settings base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/") @@ -45,7 +37,9 @@ def _build_payload( "bill_label": f"{bill.bill_type.upper()} {bill.bill_number}", "brief_summary": (action_summary or "")[:300], "bill_url": f"{base_url}/bills/{bill.bill_id}", - "milestone_tier": milestone_tier, + "action_category": action_category, + # kept for RSS/history backwards compat + "milestone_tier": "referral" if action_category == "referral" else "progress", "source": source, } @@ -62,7 +56,7 @@ def _is_duplicate(db, user_id: int, bill_id: str, event_type: str) -> bool: def emit_bill_notification( - db, bill, event_type: str, action_summary: str, milestone_tier: str = "progress" + db, bill, event_type: str, action_summary: str, action_category: str = "vote" ) -> int: """Create NotificationEvent rows for every user following this bill. Returns count.""" from app.models.follow import Follow @@ -72,7 +66,7 @@ def emit_bill_notification( if not followers: return 0 - payload = _build_payload(bill, action_summary, milestone_tier, source="bill_follow") + payload = _build_payload(bill, action_summary, action_category, source="bill_follow") count = 0 for follow in followers: if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): @@ -90,7 +84,7 @@ def emit_bill_notification( def emit_member_follow_notifications( - db, bill, event_type: str, action_summary: str, milestone_tier: str = "progress" + db, bill, event_type: str, action_summary: str, action_category: str = "vote" ) -> int: """Notify users following the bill's sponsor (dedup prevents double-alerts for bill+member followers).""" if not bill.sponsor_id: @@ -103,7 +97,7 @@ def emit_member_follow_notifications( if not followers: return 0 - payload = _build_payload(bill, action_summary, milestone_tier, source="member_follow") + payload = _build_payload(bill, action_summary, action_category, source="member_follow") count = 0 for follow in followers: if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): @@ -122,7 +116,7 @@ def emit_member_follow_notifications( def emit_topic_follow_notifications( db, bill, event_type: str, action_summary: str, topic_tags: list, - milestone_tier: str = "progress", + action_category: str = "vote", ) -> int: """Notify users following any of the bill's topic tags.""" if not topic_tags: @@ -143,7 +137,7 @@ def emit_topic_follow_notifications( if not followers: return 0 - payload = _build_payload(bill, action_summary, milestone_tier, source="topic_follow") + payload = _build_payload(bill, action_summary, action_category, source="topic_follow") count = 0 for follow in followers: if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx index 7876755..229a913 100644 --- a/frontend/app/notifications/page.tsx +++ b/frontend/app/notifications/page.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle, - FlaskConical, Clock, Calendar, FileText, AlertTriangle, + FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, } from "lucide-react"; import Link from "next/link"; import { notificationsAPI, type NotificationTestResult } from "@/lib/api"; @@ -29,6 +29,124 @@ const EVENT_META: Record [r.key, false])); + +const MODES = [ + { + key: "neutral", + label: "Follow", + description: "Bills you follow in standard mode", + preset: { new_document: true, new_amendment: false, vote: true, presidential: true, committee_report: true, calendar: false, procedural: false, referral: false }, + }, + { + key: "pocket_veto", + label: "Pocket Veto", + description: "Bills you're watching to oppose", + preset: { new_document: false, new_amendment: false, vote: true, presidential: true, committee_report: false, calendar: false, procedural: false, referral: false }, + }, + { + key: "pocket_boost", + label: "Pocket Boost", + description: "Bills you're actively supporting", + preset: { new_document: true, new_amendment: true, vote: true, presidential: true, committee_report: true, calendar: true, procedural: true, referral: true }, + }, +] as const; + +type ModeKey = "neutral" | "pocket_veto" | "pocket_boost"; + +function ModeFilterSection({ + preset, + filters, + onChange, +}: { + preset: Record; + filters: Record; + onChange: (f: Record) => void; +}) { + const milestoneCheckRef = useRef(null); + const on = MILESTONE_KEYS.filter((k) => filters[k]); + const milestoneState = on.length === 0 ? "off" : on.length === MILESTONE_KEYS.length ? "on" : "indeterminate"; + + useEffect(() => { + if (milestoneCheckRef.current) { + milestoneCheckRef.current.indeterminate = milestoneState === "indeterminate"; + } + }, [milestoneState]); + + const toggleMilestones = () => { + const val = milestoneState !== "on"; + onChange({ ...filters, ...Object.fromEntries(MILESTONE_KEYS.map((k) => [k, val])) }); + }; + + return ( +
+
+ +
+ + + + + +
+ +
+ {(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => { + const row = FILTER_ROWS.find((r) => r.key === k)!; + return ( + + ); + })} +
+
+ + +
+ ); +} + function timeAgo(iso: string) { const diff = Date.now() - new Date(iso).getTime(); const m = Math.floor(diff / 60000); @@ -98,6 +216,15 @@ export default function NotificationsPage() { const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily"); const [digestSaved, setDigestSaved] = useState(false); + // Alert filter state — one set of 8 filters per follow mode + const [alertFilters, setAlertFilters] = useState>>({ + neutral: { ...ALL_OFF, ...MODES[0].preset }, + pocket_veto: { ...ALL_OFF, ...MODES[1].preset }, + pocket_boost: { ...ALL_OFF, ...MODES[2].preset }, + }); + const [activeFilterTab, setActiveFilterTab] = useState("neutral"); + const [filtersSaved, setFiltersSaved] = useState(false); + // Detect the browser's local timezone once on mount useEffect(() => { try { @@ -125,6 +252,14 @@ export default function NotificationsPage() { } else { setQuietEnabled(false); } + if (settings.alert_filters) { + const af = settings.alert_filters as Record>; + setAlertFilters({ + neutral: { ...ALL_OFF, ...MODES[0].preset, ...(af.neutral || {}) }, + pocket_veto: { ...ALL_OFF, ...MODES[1].preset, ...(af.pocket_veto || {}) }, + pocket_boost: { ...ALL_OFF, ...MODES[2].preset, ...(af.pocket_boost || {}) }, + }); + } }, [settings]); const saveNtfy = (enabled: boolean) => { @@ -173,6 +308,13 @@ export default function NotificationsPage() { ); }; + const saveAlertFilters = () => { + update.mutate( + { alert_filters: alertFilters }, + { onSuccess: () => { setFiltersSaved(true); setTimeout(() => setFiltersSaved(false), 2000); } } + ); + }; + const testNtfy = async () => { setNtfyTesting(true); setNtfyTestResult(null); @@ -338,6 +480,56 @@ export default function NotificationsPage() { + {/* Alert Filters */} +
+
+

+ Alert Filters +

+

+ Each follow mode has its own independent filter set. "Load defaults" resets that mode to its recommended starting point. +

+
+ + {/* Tab bar */} +
+ {MODES.map((mode) => ( + + ))} +
+ + {/* Tab panels */} + {MODES.map((mode) => activeFilterTab === mode.key && ( +
+ setAlertFilters((prev) => ({ ...prev, [mode.key]: f }))} + /> +
+ +
+
+ ))} +
+ {/* Quiet Hours */}
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 22948a9..5243f01 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -182,6 +182,7 @@ export interface NotificationSettings { quiet_hours_start: number | null; quiet_hours_end: number | null; timezone: string | null; // IANA name, e.g. "America/New_York" + alert_filters: Record> | null; } export interface Collection {