"use client"; import { useState, useEffect } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle, FlaskConical, Clock, Calendar, FileText, AlertTriangle, } from "lucide-react"; import Link from "next/link"; import { notificationsAPI, type NotificationTestResult } from "@/lib/api"; 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" }, }; 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 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); // 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); } }, [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 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 && }
{/* 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 */}

Recent Alerts

Last 50 notification events for your account.

{historyLoading ? (

Loading history…

) : history.length === 0 ? (

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

) : (
{history.map((event: NotificationEvent) => { const meta = EVENT_META[event.event_type] ?? { label: "Update", icon: Bell, color: "text-muted-foreground" }; const Icon = meta.icon; const payload = event.payload ?? {}; return (
{meta.label} {payload.bill_label && ( {payload.bill_label} )} {timeAgo(event.created_at)}
{payload.bill_title && (

{payload.bill_title}

)} {payload.brief_summary && (

{payload.brief_summary}

)}
{event.dispatched_at ? "✓" : "⏳"}
); })}
)}
); }