From 247a874c8d71a51ae97d88a5ae6c077afa33eefb Mon Sep 17 00:00:00 2001 From: Jack Levy Date: Sat, 14 Mar 2026 13:21:22 -0400 Subject: [PATCH] feat: Discovery alert filters + notification reasons (v0.9.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 4th "Discovery" tab in Alert Filters for member/topic follow notifications, with per-source enable toggle, independent event-type filters, and per-entity mute chips (mute specific members/topics without unfollowing) - Enrich notification event payloads with follow_mode, matched_member_name, matched_member_id, and matched_topic so each event knows why it was created - Dispatcher branches on payload.source for member_follow/topic_follow events, checking source-level enabled toggle, per-event-type filters, and muted_ids/muted_tags - Add _build_reason helper; ntfy messages append a "why" line (📌/👤/🏷) - EventRow in notification history shows a small italic reason line - Update How It Works: fix stale member/topic paragraph, add Discovery alerts item Authored-by: Jack Levy --- .../app/workers/notification_dispatcher.py | 39 +++ backend/app/workers/notification_utils.py | 12 +- frontend/app/how-it-works/page.tsx | 18 +- frontend/app/notifications/page.tsx | 225 +++++++++++++++++- frontend/lib/types.ts | 2 +- 5 files changed, 281 insertions(+), 15 deletions(-) diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py index 70209c2..c45ce5a 100644 --- a/backend/app/workers/notification_dispatcher.py +++ b/backend/app/workers/notification_dispatcher.py @@ -54,6 +54,7 @@ _FILTER_DEFAULTS = { def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool: payload = event.payload or {} + source = payload.get("source", "bill_follow") # Map event type directly for document events if event.event_type == "new_document": @@ -69,6 +70,25 @@ def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool: all_filters = prefs.get("alert_filters") if all_filters is None: return True # user hasn't configured filters yet — send everything + + if source in ("member_follow", "topic_follow"): + source_filters = all_filters.get(source) + if source_filters is None: + return True # section not configured — send everything + if not source_filters.get("enabled", True): + return False # master toggle off + # Per-entity mute checks + if source == "member_follow": + muted_ids = source_filters.get("muted_ids") or [] + if payload.get("matched_member_id") in muted_ids: + return False + if source == "topic_follow": + muted_tags = source_filters.get("muted_tags") or [] + if payload.get("matched_topic") in muted_tags: + return False + return bool(source_filters.get(key, _FILTER_DEFAULTS.get(key, True))) + + # Bill follow — use follow mode filters (existing behaviour) mode_filters = all_filters.get(follow_mode) or {} return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True))) @@ -240,6 +260,21 @@ def send_notification_digest(self): db.close() +def _build_reason(payload: dict) -> str | None: + source = payload.get("source", "bill_follow") + mode_labels = {"pocket_veto": "Pocket Veto", "pocket_boost": "Pocket Boost", "neutral": "Following"} + if source == "bill_follow": + mode = payload.get("follow_mode", "neutral") + return f"\U0001f4cc {mode_labels.get(mode, 'Following')} this bill" + if source == "member_follow": + name = payload.get("matched_member_name") + return f"\U0001f464 You follow {name}" if name else "\U0001f464 Member you follow" + if source == "topic_follow": + topic = payload.get("matched_topic") + return f"\U0001f3f7 You follow \"{topic}\"" if topic else "\U0001f3f7 Topic you follow" + return None + + def _send_ntfy( event: NotificationEvent, topic_url: str, @@ -260,6 +295,10 @@ def _send_ntfy( if payload.get("brief_summary"): lines.append("") lines.append(payload["brief_summary"][:300]) + reason = _build_reason(payload) + if reason: + lines.append("") + lines.append(reason) message = "\n".join(lines) or bill_label headers = { diff --git a/backend/app/workers/notification_utils.py b/backend/app/workers/notification_utils.py index 439678e..9e5779a 100644 --- a/backend/app/workers/notification_utils.py +++ b/backend/app/workers/notification_utils.py @@ -75,7 +75,7 @@ def emit_bill_notification( user_id=follow.user_id, bill_id=bill.bill_id, event_type=event_type, - payload=payload, + payload={**payload, "follow_mode": follow.follow_mode}, )) count += 1 if count: @@ -97,7 +97,11 @@ def emit_member_follow_notifications( if not followers: return 0 + from app.models.member import Member + member = db.get(Member, bill.sponsor_id) payload = _build_payload(bill, action_summary, action_category, source="member_follow") + payload["matched_member_name"] = member.name if member else None + payload["matched_member_id"] = bill.sponsor_id count = 0 for follow in followers: if _is_duplicate(db, follow.user_id, bill.bill_id, event_type): @@ -125,14 +129,16 @@ def emit_topic_follow_notifications( from app.models.follow import Follow from app.models.notification import NotificationEvent - # Collect unique followers across all matching tags + # Collect unique followers across all matching tags, recording the first matching tag per user seen_user_ids: set[int] = set() followers = [] + follower_topic: dict[int, str] = {} for tag in topic_tags: for follow in db.query(Follow).filter_by(follow_type="topic", follow_value=tag).all(): if follow.user_id not in seen_user_ids: seen_user_ids.add(follow.user_id) followers.append(follow) + follower_topic[follow.user_id] = tag if not followers: return 0 @@ -146,7 +152,7 @@ def emit_topic_follow_notifications( user_id=follow.user_id, bill_id=bill.bill_id, event_type=event_type, - payload=payload, + payload={**payload, "matched_topic": follower_topic.get(follow.user_id)}, )) count += 1 if count: diff --git a/frontend/app/how-it-works/page.tsx b/frontend/app/how-it-works/page.tsx index 485e5d3..e128c49 100644 --- a/frontend/app/how-it-works/page.tsx +++ b/frontend/app/how-it-works/page.tsx @@ -120,9 +120,12 @@ export default function HowItWorksPage() {

- You can also follow members (alerts when they sponsor new bills) and{" "} - topics (alerts when new bills matching that topic are briefed). - Member and topic follows use the Follow mode filters. + You can also follow members and topics. + When a followed member sponsors a bill, or a new bill matches a followed topic, you'll + receive a Discovery alert. These have their own independent filter set in{" "} + Notifications → Alert Filters → Discovery. + By default, all followed members and topics trigger notifications — you can mute individual + ones without unfollowing them.

@@ -176,6 +179,15 @@ export default function HowItWorksPage() { A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader (Feedly, NetNewsWire, etc.). Completely independent of ntfy. + + Member and topic follows generate Discovery alerts — separate from the bills you follow + directly. In{" "} + Alert Filters → Discovery, + you can enable or disable these independently, tune which event types trigger them, and + mute specific members or topics you'd rather not hear about without unfollowing them. + Each notification also shows a “why” line so you always know which follow + triggered it. + diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx index 0ef4774..08a92b1 100644 --- a/frontend/app/notifications/page.tsx +++ b/frontend/app/notifications/page.tsx @@ -1,13 +1,13 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueries } from "@tanstack/react-query"; import { Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle, - FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, + FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, X, } from "lucide-react"; import Link from "next/link"; -import { notificationsAPI, type NotificationTestResult } from "@/lib/api"; +import { notificationsAPI, membersAPI, type NotificationTestResult } from "@/lib/api"; import { useFollows } from "@/lib/hooks/useFollows"; import type { NotificationEvent } from "@/lib/types"; @@ -66,6 +66,25 @@ const MODES = [ ] as const; type ModeKey = "neutral" | "pocket_veto" | "pocket_boost"; +type DiscoverySourceKey = "member_follow" | "topic_follow"; +type FilterTabKey = ModeKey | "discovery"; + +const DISCOVERY_SOURCES = [ + { + key: "member_follow" as DiscoverySourceKey, + label: "Member Follows", + description: "Bills sponsored by members you follow", + preset: { new_document: false, new_amendment: false, vote: true, presidential: true, + committee_report: true, calendar: false, procedural: false, referral: false }, + }, + { + key: "topic_follow" as DiscoverySourceKey, + label: "Topic Follows", + description: "Bills matching topics you follow", + preset: { new_document: false, new_amendment: false, vote: true, presidential: true, + committee_report: false, calendar: false, procedural: false, referral: false }, + }, +] as const; function ModeFilterSection({ preset, @@ -73,8 +92,8 @@ function ModeFilterSection({ onChange, }: { preset: Record; - filters: Record; - onChange: (f: Record) => void; + filters: Record; + onChange: (f: Record) => void; }) { const milestoneCheckRef = useRef(null); const on = MILESTONE_KEYS.filter((k) => filters[k]); @@ -217,14 +236,40 @@ export default function NotificationsPage() { const [digestSaved, setDigestSaved] = useState(false); // Alert filter state — one set of 8 filters per follow mode - const [alertFilters, setAlertFilters] = useState>>({ + 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 [discoveryFilters, setDiscoveryFilters] = useState>>({ + member_follow: { enabled: true, ...DISCOVERY_SOURCES[0].preset }, + topic_follow: { enabled: true, ...DISCOVERY_SOURCES[1].preset }, + }); + const [activeFilterTab, setActiveFilterTab] = useState("neutral"); const [filtersSaved, setFiltersSaved] = useState(false); + // Per-entity mute lists for Discovery — plain arrays; names resolved from memberById at render time + const [mutedMemberIds, setMutedMemberIds] = useState([]); + const [mutedTopicTags, setMutedTopicTags] = useState([]); + + // Derive member/topic follows for the mute dropdowns + const memberFollows = follows.filter((f) => f.follow_type === "member"); + const topicFollows = follows.filter((f) => f.follow_type === "topic"); + + // Batch-fetch member names so the "Mute a member…" dropdown shows real names + const memberQueries = useQueries({ + queries: memberFollows.map((f) => ({ + queryKey: ["member", f.follow_value], + queryFn: () => membersAPI.get(f.follow_value), + staleTime: 5 * 60 * 1000, + })), + }); + const memberById: Record = Object.fromEntries( + memberFollows + .map((f, i) => [f.follow_value, memberQueries[i]?.data?.name]) + .filter(([, name]) => name) + ); + // Detect the browser's local timezone once on mount useEffect(() => { try { @@ -259,6 +304,12 @@ export default function NotificationsPage() { pocket_veto: { ...ALL_OFF, ...MODES[1].preset, ...(af.pocket_veto || {}) }, pocket_boost: { ...ALL_OFF, ...MODES[2].preset, ...(af.pocket_boost || {}) }, }); + setDiscoveryFilters({ + member_follow: { enabled: true, ...DISCOVERY_SOURCES[0].preset, ...(af.member_follow || {}) }, + topic_follow: { enabled: true, ...DISCOVERY_SOURCES[1].preset, ...(af.topic_follow || {}) }, + }); + setMutedMemberIds(((af.member_follow as Record)?.muted_ids as string[]) || []); + setMutedTopicTags(((af.topic_follow as Record)?.muted_tags as string[]) || []); } }, [settings]); @@ -310,7 +361,11 @@ export default function NotificationsPage() { const saveAlertFilters = () => { update.mutate( - { alert_filters: alertFilters }, + { alert_filters: { + ...alertFilters, + member_follow: { ...discoveryFilters.member_follow, muted_ids: mutedMemberIds }, + topic_follow: { ...discoveryFilters.topic_follow, muted_tags: mutedTopicTags }, + } }, { onSuccess: () => { setFiltersSaved(true); setTimeout(() => setFiltersSaved(false), 2000); } } ); }; @@ -506,6 +561,16 @@ export default function NotificationsPage() { {mode.label} ))} + {/* Tab panels */} @@ -528,6 +593,130 @@ export default function NotificationsPage() { ))} + + {activeFilterTab === "discovery" && ( +
+ {/* Member follows */} + {(() => { + const src = DISCOVERY_SOURCES[0]; + const srcFilters = discoveryFilters[src.key]; + const { enabled, ...alertOnly } = srcFilters; + const unmutedMembers = memberFollows.filter((f) => !mutedMemberIds.includes(f.follow_value)); + return ( +
+ + {!!enabled && ( +
+ setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...f, enabled: true } }))} /> + {/* Muted members */} +
+

Muted members

+
+ {mutedMemberIds.length === 0 ? ( + None — all followed members will trigger notifications + ) : mutedMemberIds.map((id) => ( + + {memberById[id] ?? id} + + + ))} +
+ {unmutedMembers.length > 0 && ( + + )} +
+
+ )} +
+ ); + })()} + +
+ + {/* Topic follows */} + {(() => { + const src = DISCOVERY_SOURCES[1]; + const srcFilters = discoveryFilters[src.key]; + const { enabled, ...alertOnly } = srcFilters; + const unmutedTopics = topicFollows.filter((f) => !mutedTopicTags.includes(f.follow_value)); + return ( +
+ + {!!enabled && ( +
+ setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...f, enabled: true } }))} /> + {/* Muted topics */} +
+

Muted topics

+
+ {mutedTopicTags.length === 0 ? ( + None — all followed topics will trigger notifications + ) : mutedTopicTags.map((tag) => ( + + {tag} + + + ))} +
+ {unmutedTopics.length > 0 && ( + + )} +
+
+ )} +
+ ); + })()} + +
+ +
+
+ )} {/* Quiet Hours */} @@ -734,6 +923,26 @@ export default function NotificationsPage() { {billTitle && (

{billTitle}

)} + {(() => { + const src = p.source as string | undefined; + const modeLabels: Record = { + pocket_veto: "Pocket Veto", pocket_boost: "Pocket Boost", neutral: "Following", + }; + let reason: string | null = null; + if (src === "bill_follow") { + const mode = p.follow_mode as string | undefined; + reason = mode ? `${modeLabels[mode] ?? "Following"} this bill` : null; + } else if (src === "member_follow") { + const name = p.matched_member_name as string | undefined; + reason = name ? `You follow ${name}` : "Member you follow"; + } else if (src === "topic_follow") { + const topic = p.matched_topic as string | undefined; + reason = topic ? `You follow "${topic}"` : "Topic you follow"; + } + return reason ? ( +

{reason}

+ ) : null; + })()} {briefSummary && (

{briefSummary}

)} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 3c147b1..d171c62 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -182,7 +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; + alert_filters: Record> | null; } export interface Collection {