"use client"; 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, Filter, } from "lucide-react"; import Link from "next/link"; import { notificationsAPI, 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: 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); 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(), }); // 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); // 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 [activeFilterTab, setActiveFilterTab] = useState("neutral"); const [filtersSaved, setFiltersSaved] = 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; setTopicUrl(settings.ntfy_topic_url ?? ""); setAuthMethod(settings.ntfy_auth_method ?? "none"); setToken(settings.ntfy_token ?? ""); setUsername(settings.ntfy_username ?? ""); 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); 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 || {}) }, }); } }, [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 : "", ntfy_enabled: enabled, }, { onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } } ); }; 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 }, { 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.

{/* ntfy */}

Push Notifications (ntfy)

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

{settings?.ntfy_enabled && ( Active )}

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 && }
{/* 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 */}

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}

)} {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) => ( ))}
)} ); })()}
); }