"use client"; import { useState, useEffect, useRef } from "react"; import { useQuery, useMutation, useQueries } from "@tanstack/react-query"; import { Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle, FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, X, Mail, Send, MessageSquare, } from "lucide-react"; import Link from "next/link"; import { notificationsAPI, membersAPI, type NotificationTestResult } from "@/lib/api"; import { useFollows } from "@/lib/hooks/useFollows"; import type { NotificationEvent } from "@/lib/types"; const AUTH_METHODS = [ { value: "none", label: "No authentication", hint: "Public ntfy.sh topics or open self-hosted servers" }, { value: "token", label: "Access token", hint: "ntfy token (tk_...)" }, { value: "basic", label: "Username & password", hint: "For servers behind HTTP basic auth or nginx ACL" }, ]; 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" }, new_amendment: { label: "Amendment Filed", icon: FileText, color: "text-purple-500" }, bill_updated: { label: "Bill Updated", icon: AlertTriangle, color: "text-orange-500" }, }; const FILTER_ROWS = [ { key: "new_document", label: "New bill text", hint: "The full text of the bill is published" }, { key: "new_amendment", label: "Amendment filed", hint: "An amendment is filed against the bill" }, { key: "vote", label: "Chamber votes", hint: "Bill passes or fails a House or Senate vote" }, { key: "presidential", label: "Presidential action", hint: "Signed into law, vetoed, or enacted" }, { key: "committee_report", label: "Committee report", hint: "Committee votes to advance or kill the bill" }, { key: "calendar", label: "Calendar placement", hint: "Scheduled for floor consideration" }, { key: "procedural", label: "Procedural", hint: "Senate cloture votes; conference committee activity" }, { key: "referral", label: "Committee referral", hint: "Bill assigned to a committee — first step for almost every bill" }, ] as const; const MILESTONE_KEYS = ["vote", "presidential", "committee_report", "calendar", "procedural"] as const; const ALL_OFF = Object.fromEntries(FILTER_ROWS.map((r) => [r.key, false])); const MODES = [ { key: "neutral", label: "Follow", description: "Bills you follow in standard mode", preset: { new_document: true, new_amendment: true, 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"; 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, 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); if (m < 1) return "just now"; if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; } export default function NotificationsPage() { const { data: settings, refetch } = useQuery({ queryKey: ["notification-settings"], queryFn: () => notificationsAPI.getSettings(), }); const { data: history = [], isLoading: historyLoading } = useQuery({ queryKey: ["notification-history"], queryFn: () => notificationsAPI.getHistory(), staleTime: 60 * 1000, }); const { data: follows = [] } = useFollows(); const directlyFollowedBillIds = new Set( follows.filter((f) => f.follow_type === "bill").map((f) => f.follow_value) ); const update = useMutation({ mutationFn: (data: Parameters[0]) => notificationsAPI.updateSettings(data), onSuccess: () => refetch(), }); const resetRss = useMutation({ mutationFn: () => notificationsAPI.resetRssToken(), onSuccess: () => refetch(), }); // Channel tab const [activeChannelTab, setActiveChannelTab] = useState<"ntfy" | "email" | "telegram" | "discord">("ntfy"); // ntfy form state const [topicUrl, setTopicUrl] = useState(""); const [authMethod, setAuthMethod] = useState("none"); const [token, setToken] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [ntfySaved, setNtfySaved] = useState(false); // Email form state const [emailAddress, setEmailAddress] = useState(""); const [emailEnabled, setEmailEnabled] = useState(false); const [emailSaved, setEmailSaved] = useState(false); const [emailTesting, setEmailTesting] = useState(false); const [emailTestResult, setEmailTestResult] = useState(null); // Test state const [ntfyTesting, setNtfyTesting] = useState(false); const [ntfyTestResult, setNtfyTestResult] = useState(null); const [rssTesting, setRssTesting] = useState(false); const [rssTestResult, setRssTestResult] = useState(null); // RSS state const [rssSaved, setRssSaved] = useState(false); const [copied, setCopied] = useState(false); // Quiet hours state const [quietEnabled, setQuietEnabled] = useState(false); 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); // 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 [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 { 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; setTopicUrl(settings.ntfy_topic_url ?? ""); setAuthMethod(settings.ntfy_auth_method ?? "none"); setToken(settings.ntfy_token ?? ""); setUsername(settings.ntfy_username ?? ""); setPassword(""); // never pre-fill — password_set bool shows whether one is stored setEmailAddress(settings.email_address ?? ""); setEmailEnabled(settings.email_enabled ?? false); 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); setQuietEnd(settings.quiet_hours_end ?? 8); } 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 || {}) }, }); 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]); const saveNtfy = (enabled: boolean) => { update.mutate( { ntfy_topic_url: topicUrl, ntfy_auth_method: authMethod, ntfy_token: authMethod === "token" ? token : "", ntfy_username: authMethod === "basic" ? username : "", ntfy_password: authMethod === "basic" ? (password || undefined) : "", ntfy_enabled: enabled, }, { onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } } ); }; const saveEmail = (enabled: boolean) => { update.mutate( { email_address: emailAddress, email_enabled: enabled }, { onSuccess: () => { setEmailSaved(true); setTimeout(() => setEmailSaved(false), 2000); } } ); }; const testEmailFn = async () => { setEmailTesting(true); setEmailTestResult(null); try { const result = await notificationsAPI.testEmail(); setEmailTestResult(result); } catch (e: unknown) { const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? (e instanceof Error ? e.message : "Request failed"); setEmailTestResult({ status: "error", detail }); } finally { setEmailTesting(false); } }; const toggleRss = (enabled: boolean) => { update.mutate( { rss_enabled: enabled }, { onSuccess: () => { setRssSaved(true); setTimeout(() => setRssSaved(false), 2000); } } ); }; const saveQuietHours = () => { const onSuccess = () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); }; if (quietEnabled) { update.mutate( { 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 quiet hours + timezone update.mutate({ quiet_hours_start: -1 }, { onSuccess }); } }; const saveDigest = () => { update.mutate( { digest_enabled: digestEnabled, digest_frequency: digestFrequency }, { onSuccess: () => { setDigestSaved(true); setTimeout(() => setDigestSaved(false), 2000); } } ); }; const saveAlertFilters = () => { update.mutate( { 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); } } ); }; const testNtfy = async () => { setNtfyTesting(true); setNtfyTestResult(null); try { const result = await notificationsAPI.testNtfy({ ntfy_topic_url: topicUrl, ntfy_auth_method: authMethod, ntfy_token: authMethod === "token" ? token : "", ntfy_username: authMethod === "basic" ? username : "", ntfy_password: authMethod === "basic" ? password : "", }); setNtfyTestResult(result); } catch (e: unknown) { const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? (e instanceof Error ? e.message : "Request failed"); setNtfyTestResult({ status: "error", detail }); } finally { setNtfyTesting(false); } }; const testRss = async () => { setRssTesting(true); setRssTestResult(null); try { const result = await notificationsAPI.testRss(); setRssTestResult(result); } catch (e: unknown) { const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? (e instanceof Error ? e.message : "Feed check failed"); setRssTestResult({ status: "error", detail }); } finally { setRssTesting(false); } }; const rssUrl = settings?.rss_token ? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml` : null; const ResultBadge = ({ result }: { result: NotificationTestResult }) => (
{result.status === "ok" ? : } {result.detail}
); return (

Notifications

Get alerted when bills you follow are updated, new text is published, or amendments are filed.

{/* Notification Channels */}

Notification Channels

Configure where you receive push notifications. Enable one or more channels.

{/* Channel tab bar */}
{([ { key: "ntfy", label: "ntfy", icon: Bell, active: settings?.ntfy_enabled }, { key: "email", label: "Email", icon: Mail, active: settings?.email_enabled }, { key: "telegram", label: "Telegram", icon: Send, active: false }, { key: "discord", label: "Discord", icon: MessageSquare, active: false }, ] as const).map(({ key, label, icon: Icon, active }) => ( ))}
{/* ntfy tab */} {activeChannelTab === "ntfy" && (

Uses ntfy — a free, open-source push notification service. Use the public ntfy.sh server or your own self-hosted instance.

The full URL to your ntfy topic, e.g.{" "} https://ntfy.sh/my-pocketveto-alerts

setTopicUrl(e.target.value)} className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
{AUTH_METHODS.map(({ value, label, hint }) => ( ))}
{authMethod === "token" && (
setToken(e.target.value)} className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
)} {authMethod === "basic" && (
setUsername(e.target.value)} className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
setPassword(e.target.value)} className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
)}
{settings?.ntfy_enabled && ( )}
{ntfyTestResult && }
)} {/* Email tab */} {activeChannelTab === "email" && (

Receive bill alerts as plain-text emails. Requires SMTP to be configured on the server (see .env).

The email address to send alerts to.

setEmailAddress(e.target.value)} className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
{settings?.email_enabled && ( )}
{emailTestResult && }
)} {/* Telegram tab — coming soon */} {activeChannelTab === "telegram" && (

Telegram Notifications

Receive PocketVeto alerts directly in Telegram via a dedicated bot. Will require a Telegram Bot Token and your Chat ID.

Coming soon
)} {/* Discord tab — coming soon */} {activeChannelTab === "discord" && (

Discord Notifications

Post bill alerts to a Discord channel via a webhook URL. Will support per-channel routing and @role mentions.

Coming soon
)}
{/* 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 }))} />
))} {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 */}

Quiet Hours

Pause ntfy push notifications during set hours. Events accumulate and fire as a batch when quiet hours end. RSS is unaffected.

{quietEnabled && (
{quietStart > quietEnd && ( (overnight window) )}
{detectedTimezone && (

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

)}
)}
{/* Digest */}

Digest Mode

Instead of per-event push notifications, receive a single bundled ntfy summary on a schedule. RSS feed is always real-time regardless of this setting.

{digestEnabled && (
{(["daily", "weekly"] as const).map((freq) => ( ))}
)}
{/* RSS */}

RSS Feed

A private, tokenized RSS feed of your bill alerts — subscribe in any RSS reader. Independent of ntfy; enable either or both.

{settings?.rss_enabled && ( Active )}
{rssUrl && (
{rssUrl}
)}
{!settings?.rss_enabled ? ( ) : ( )} {rssUrl && ( <> )}
{rssTestResult && }
{/* Notification History */} {(() => { const directEvents = history.filter((e: NotificationEvent) => { const src = (e.payload as Record)?.source as string | undefined; if (src === "topic_follow") return false; if (src === "bill_follow" || src === "member_follow") return true; // Legacy events (no source field): treat as direct if bill is followed return directlyFollowedBillIds.has(e.bill_id); }); const topicEvents = history.filter((e: NotificationEvent) => { const src = (e.payload as Record)?.source as string | undefined; if (src === "topic_follow") return true; if (src) return false; return !directlyFollowedBillIds.has(e.bill_id); }); const EventRow = ({ event, showDispatch }: { event: NotificationEvent; showDispatch: boolean }) => { const meta = EVENT_META[event.event_type] ?? { label: "Update", icon: Bell, color: "text-muted-foreground" }; const Icon = meta.icon; const p = (event.payload ?? {}) as Record; const billLabel = p.bill_label as string | undefined; const billTitle = p.bill_title as string | undefined; const briefSummary = p.brief_summary as string | undefined; return (
{meta.label} {billLabel && ( {billLabel} )} {timeAgo(event.created_at)}
{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}

)}
{showDispatch && ( {event.dispatched_at ? "✓" : "⏳"} )}
); }; return ( <>

Recent Alerts

Notifications for bills and members you directly follow. Last 50 events.

{historyLoading ? (

Loading history…

) : directEvents.length === 0 ? (

No alerts yet. Follow some bills and check back after the next poll.

) : (
{directEvents.map((event: NotificationEvent) => ( ))}
)}
{topicEvents.length > 0 && (

Based on your topic follows

Bills matching topics you follow that have had recent activity. Milestone events (passed, signed, new text) are pushed; routine referrals are not. Follow a bill directly to get all updates.

{topicEvents.map((event: NotificationEvent) => ( ))}
)} ); })()}
); }