Files
PocketVeto/frontend/app/notifications/page.tsx
Jack Levy af821dad78 fix: show dispatch indicator (✓) for topic-follow events too
Topic events that fire a push notification (milestones like
calendar placement, passed, new text) now show ✓ in the
"Based on your topic follows" section, consistent with the
Recent Alerts section. Also clarifies the section description
to explain which events are pushed vs suppressed.

Authored-By: Jack Levy
2026-03-02 16:09:13 -05:00

610 lines
27 KiB
TypeScript

"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 { 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<string, { label: string; icon: typeof Bell; color: string }> = {
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 { 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<typeof notificationsAPI.updateSettings>[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<NotificationTestResult | null>(null);
const [rssTesting, setRssTesting] = useState(false);
const [rssTestResult, setRssTestResult] = useState<NotificationTestResult | null>(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<string>("");
const [savedTimezone, setSavedTimezone] = useState<string | null>(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 }) => (
<div className={`flex items-start gap-2 text-xs rounded-md px-3 py-2 ${
result.status === "ok"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400"
}`}>
{result.status === "ok"
? <CheckCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
: <XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />}
{result.detail}
</div>
);
return (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Bell className="w-5 h-5" /> Notifications
</h1>
<p className="text-muted-foreground text-sm mt-1">
Get alerted when bills you follow are updated, new text is published, or amendments are filed.
</p>
</div>
{/* ntfy */}
<section className="bg-card border border-border rounded-lg p-6 space-y-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Push Notifications (ntfy)
</h2>
<p className="text-xs text-muted-foreground mt-1">
Uses <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a> a free, open-source push notification service.
Use the public <code className="bg-muted px-1 rounded">ntfy.sh</code> server or your own self-hosted instance.
</p>
</div>
{settings?.ntfy_enabled && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
<CheckCircle className="w-3.5 h-3.5" /> Active
</span>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Topic URL</label>
<p className="text-xs text-muted-foreground">
The full URL to your ntfy topic, e.g.{" "}
<code className="bg-muted px-1 rounded">https://ntfy.sh/my-pocketveto-alerts</code>
</p>
<input
type="url"
placeholder="https://ntfy.sh/your-topic"
value={topicUrl}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Authentication</label>
<div className="space-y-2">
{AUTH_METHODS.map(({ value, label, hint }) => (
<label key={value} className="flex items-start gap-3 cursor-pointer">
<input type="radio" name="ntfy-auth" value={value} checked={authMethod === value}
onChange={() => setAuthMethod(value)} className="mt-0.5" />
<div>
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
</label>
))}
</div>
</div>
{authMethod === "token" && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Access Token</label>
<input type="password" placeholder="tk_..." value={token}
onChange={(e) => 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" />
</div>
)}
{authMethod === "basic" && (
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">Username</label>
<input type="text" placeholder="your-username" value={username}
onChange={(e) => 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" />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Password</label>
<input type="password" placeholder="your-password" value={password}
onChange={(e) => 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" />
</div>
</div>
)}
<div className="space-y-3 pt-1 border-t border-border">
<div className="flex items-center gap-3">
<button onClick={() => saveNtfy(true)} disabled={!topicUrl.trim() || update.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors">
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
{ntfySaved ? "Saved!" : "Save & Enable"}
</button>
<button onClick={testNtfy} disabled={!topicUrl.trim() || ntfyTesting}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors">
{ntfyTesting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
{ntfyTesting ? "Sending…" : "Test"}
</button>
{settings?.ntfy_enabled && (
<button onClick={() => saveNtfy(false)} disabled={update.isPending}
className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Disable
</button>
)}
</div>
{ntfyTestResult && <ResultBadge result={ntfyTestResult} />}
</div>
</section>
{/* Quiet Hours */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Clock className="w-4 h-4" /> Quiet Hours
</h2>
<p className="text-xs text-muted-foreground mt-1">
Pause ntfy push notifications during set hours. Events accumulate and fire as a batch when quiet hours end.
RSS is unaffected.
</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={quietEnabled} onChange={(e) => setQuietEnabled(e.target.checked)}
className="rounded" />
<span className="text-sm font-medium">Enable quiet hours</span>
</label>
{quietEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">From</label>
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">To</label>
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
{quietStart > quietEnd && (
<span className="text-xs text-muted-foreground">(overnight window)</span>
)}
</div>
{detectedTimezone && (
<p className="text-xs text-muted-foreground">
Times are in your local timezone: <span className="font-medium text-foreground">{detectedTimezone}</span>
{savedTimezone && savedTimezone !== detectedTimezone && (
<span className="text-amber-600 dark:text-amber-400"> · saved as {savedTimezone} save to update</span>
)}
</p>
)}
</div>
)}
<button onClick={saveQuietHours} disabled={update.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors">
{quietSaved ? <CheckCircle className="w-3.5 h-3.5" /> : null}
{quietSaved ? "Saved!" : "Save Quiet Hours"}
</button>
</section>
{/* Digest */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Calendar className="w-4 h-4" /> Digest Mode
</h2>
<p className="text-xs text-muted-foreground mt-1">
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.
</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={digestEnabled} onChange={(e) => setDigestEnabled(e.target.checked)}
className="rounded" />
<span className="text-sm font-medium">Enable digest mode</span>
</label>
{digestEnabled && (
<div className="flex items-center gap-3">
<label className="text-sm text-muted-foreground">Frequency</label>
<div className="flex gap-3">
{(["daily", "weekly"] as const).map((freq) => (
<label key={freq} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="digest-freq" value={freq} checked={digestFrequency === freq}
onChange={() => setDigestFrequency(freq)} />
<span className="text-sm capitalize">{freq}</span>
{freq === "weekly" && <span className="text-xs text-muted-foreground">(Mondays, 8 AM UTC)</span>}
{freq === "daily" && <span className="text-xs text-muted-foreground">(8 AM UTC)</span>}
</label>
))}
</div>
</div>
)}
<button onClick={saveDigest} disabled={update.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors">
{digestSaved ? <CheckCircle className="w-3.5 h-3.5" /> : null}
{digestSaved ? "Saved!" : "Save Digest Settings"}
</button>
</section>
{/* RSS */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Rss className="w-4 h-4" /> RSS Feed
</h2>
<p className="text-xs text-muted-foreground mt-1">
A private, tokenized RSS feed of your bill alerts subscribe in any RSS reader.
Independent of ntfy; enable either or both.
</p>
</div>
{settings?.rss_enabled && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
<CheckCircle className="w-3.5 h-3.5" /> Active
</span>
)}
</div>
{rssUrl && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Your feed URL</label>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted px-2 py-2 rounded truncate">{rssUrl}</code>
<button onClick={() => { navigator.clipboard.writeText(rssUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
className="shrink-0 p-1.5 rounded hover:bg-accent transition-colors" title="Copy RSS URL">
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-muted-foreground" />}
</button>
</div>
</div>
)}
<div className="space-y-3 pt-1 border-t border-border">
<div className="flex items-center gap-3">
{!settings?.rss_enabled ? (
<button onClick={() => toggleRss(true)} disabled={update.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors">
{rssSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Rss className="w-3.5 h-3.5" />}
{rssSaved ? "Enabled!" : "Enable RSS"}
</button>
) : (
<button onClick={() => toggleRss(false)} disabled={update.isPending}
className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Disable RSS
</button>
)}
{rssUrl && (
<>
<button onClick={testRss} disabled={rssTesting}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors">
{rssTesting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
{rssTesting ? "Checking…" : "Test"}
</button>
<button onClick={() => resetRss.mutate()} disabled={resetRss.isPending}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="Generate a new URL — old URL will stop working">
<RefreshCw className="w-3 h-3" /> Regenerate URL
</button>
</>
)}
</div>
{rssTestResult && <ResultBadge result={rssTestResult} />}
</div>
</section>
{/* Notification History */}
{(() => {
const directEvents = history.filter((e: NotificationEvent) => {
const src = (e.payload as Record<string, unknown>)?.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<string, unknown>)?.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<string, unknown>;
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 (
<div className="flex items-start gap-3 py-3">
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">{meta.label}</span>
{billLabel && (
<Link href={`/bills/${event.bill_id}`}
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded hover:text-primary transition-colors">
{billLabel}
</Link>
)}
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
</div>
{billTitle && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
)}
{briefSummary && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{briefSummary}</p>
)}
</div>
{showDispatch && (
<span className={`text-xs shrink-0 ${event.dispatched_at ? "text-green-500" : "text-amber-500"}`}
title={event.dispatched_at ? `Sent ${timeAgo(event.dispatched_at)}` : "Pending dispatch"}>
{event.dispatched_at ? "✓" : "⏳"}
</span>
)}
</div>
);
};
return (
<>
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Recent Alerts
</h2>
<p className="text-xs text-muted-foreground mt-1">
Notifications for bills and members you directly follow. Last 50 events.
</p>
</div>
{historyLoading ? (
<p className="text-sm text-muted-foreground">Loading history</p>
) : directEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">
No alerts yet. Follow some bills and check back after the next poll.
</p>
) : (
<div className="divide-y divide-border">
{directEvents.map((event: NotificationEvent) => (
<EventRow key={event.id} event={event} showDispatch />
))}
</div>
)}
</section>
{topicEvents.length > 0 && (
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4 text-muted-foreground" /> Based on your topic follows
</h2>
<p className="text-xs text-muted-foreground mt-1">
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.
</p>
</div>
<div className="divide-y divide-border">
{topicEvents.map((event: NotificationEvent) => (
<EventRow key={event.id} event={event} showDispatch />
))}
</div>
</section>
)}
</>
);
})()}
</div>
);
}