feat: PocketVeto v1.0.0 — initial public release

Self-hosted US Congress monitoring platform with AI policy briefs,
bill/member/topic follows, ntfy + RSS + email notifications,
alignment scoring, collections, and draft-letter generator.

Authored by: Jack Levy
This commit is contained in:
Jack Levy
2026-03-15 01:35:01 -04:00
commit 4c86a5b9ca
150 changed files with 19859 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { alignmentAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
import type { AlignmentScore } from "@/lib/types";
function partyColor(party?: string) {
if (!party) return "bg-muted text-muted-foreground";
const p = party.toLowerCase();
if (p.includes("republican") || p === "r") return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400";
if (p.includes("democrat") || p === "d") return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
return "bg-muted text-muted-foreground";
}
function AlignmentBar({ pct }: { pct: number }) {
const color =
pct >= 66 ? "bg-emerald-500" : pct >= 33 ? "bg-amber-500" : "bg-red-500";
return (
<div className="flex-1 h-1.5 bg-muted rounded overflow-hidden">
<div className={`h-full rounded ${color}`} style={{ width: `${pct}%` }} />
</div>
);
}
function MemberRow({ member }: { member: AlignmentScore }) {
const pct = member.alignment_pct;
return (
<Link
href={`/members/${member.bioguide_id}`}
className="flex items-center gap-3 py-3 hover:bg-accent/50 rounded-md px-2 -mx-2 transition-colors"
>
{member.photo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={member.photo_url}
alt={member.name}
className="w-9 h-9 rounded-full object-cover shrink-0 border border-border"
/>
) : (
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center shrink-0 border border-border text-xs font-medium text-muted-foreground">
{member.name.charAt(0)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{member.name}</span>
<span className="text-sm font-mono font-semibold shrink-0">
{pct != null ? `${Math.round(pct)}%` : "—"}
</span>
</div>
<div className="flex items-center gap-2 mt-1">
{member.party && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${partyColor(member.party)}`}>
{member.party.charAt(0)}
</span>
)}
{member.state && (
<span className="text-xs text-muted-foreground">{member.state}</span>
)}
{pct != null && <AlignmentBar pct={pct} />}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{member.aligned} aligned · {member.opposed} opposed · {member.total} overlapping vote{member.total !== 1 ? "s" : ""}
</p>
</div>
</Link>
);
}
export default function AlignmentPage() {
const currentUser = useAuthStore((s) => s.user);
const { data, isLoading } = useQuery({
queryKey: ["alignment"],
queryFn: () => alignmentAPI.get(),
enabled: !!currentUser,
staleTime: 5 * 60 * 1000,
});
if (!currentUser) {
return (
<div className="text-center py-20 space-y-3">
<p className="text-muted-foreground">Sign in to see your representation alignment.</p>
<Link href="/login" className="text-sm text-primary hover:underline">Sign in </Link>
</div>
);
}
if (isLoading) {
return <div className="text-center py-20 text-muted-foreground text-sm">Loading alignment data</div>;
}
const members = data?.members ?? [];
const hasStance = (data?.total_bills_with_stance ?? 0) > 0;
const hasFollowedMembers = members.length > 0 || (data?.total_bills_with_votes ?? 0) > 0;
return (
<div className="space-y-6 max-w-xl">
<div>
<h1 className="text-2xl font-bold">Representation Alignment</h1>
<p className="text-sm text-muted-foreground mt-1">
How often do your followed members vote with your bill positions?
</p>
</div>
{/* How it works */}
<div className="bg-card border border-border rounded-lg p-4 text-sm space-y-1.5">
<p className="font-medium">How this works</p>
<p className="text-muted-foreground leading-relaxed">
For every bill you follow with <strong>Pocket Boost</strong> or <strong>Pocket Veto</strong>, we check
how each of your followed members voted on that bill. A Yea vote on a boosted bill counts as
aligned; a Nay vote on a vetoed bill counts as aligned. All other combinations count as opposed.
Not Voting and Present are excluded.
</p>
{data && (
<p className="text-xs text-muted-foreground pt-1">
{data.total_bills_with_stance} bill{data.total_bills_with_stance !== 1 ? "s" : ""} with a stance ·{" "}
{data.total_bills_with_votes} had roll-call votes
</p>
)}
</div>
{/* Empty states */}
{!hasStance && (
<div className="text-center py-12 text-muted-foreground space-y-2">
<p className="text-sm">No bill stances yet.</p>
<p className="text-xs">
Follow some bills with{" "}
<Link href="/bills" className="text-primary hover:underline">Pocket Boost or Pocket Veto</Link>{" "}
to start tracking alignment.
</p>
</div>
)}
{hasStance && members.length === 0 && (
<div className="text-center py-12 text-muted-foreground space-y-2">
<p className="text-sm">No overlapping votes found yet.</p>
<p className="text-xs">
Make sure you&apos;re{" "}
<Link href="/members" className="text-primary hover:underline">following some members</Link>
, and that those members have voted on bills you&apos;ve staked a position on.
</p>
</div>
)}
{/* Member list */}
{members.length > 0 && (
<div className="bg-card border border-border rounded-lg p-4">
<div className="divide-y divide-border">
{members.map((m) => (
<MemberRow key={m.bioguide_id} member={m} />
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import { use, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { ArrowLeft, ExternalLink, FileX, Tag, User } from "lucide-react";
import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills";
import { useAuthStore } from "@/stores/authStore";
import { BriefPanel } from "@/components/bills/BriefPanel";
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
import { NotesPanel } from "@/components/bills/NotesPanel";
import { ActionTimeline } from "@/components/bills/ActionTimeline";
import { VotePanel } from "@/components/bills/VotePanel";
import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton";
import { CollectionPicker } from "@/components/bills/CollectionPicker";
import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
import { TOPIC_LABEL, TOPIC_TAGS } from "@/lib/topics";
const TABS = [
{ id: "analysis", label: "Analysis" },
{ id: "timeline", label: "Timeline" },
{ id: "votes", label: "Votes" },
{ id: "notes", label: "Notes" },
] as const;
type TabId = typeof TABS[number]["id"];
export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const billId = decodeURIComponent(id);
const [activeTab, setActiveTab] = useState<TabId>("analysis");
const token = useAuthStore((s) => s.token);
const { data: bill, isLoading } = useBill(billId);
const { data: trendData } = useBillTrend(billId, 30);
const { data: newsArticles, refetch: refetchNews } = useBillNews(billId);
const { data: note } = useQuery({
queryKey: ["note", billId],
queryFn: () => import("@/lib/api").then((m) => m.notesAPI.get(billId)),
enabled: !!token,
retry: false,
throwOnError: false,
});
const newsRetryRef = useRef(0);
useEffect(() => { newsRetryRef.current = 0; }, [billId]);
useEffect(() => {
if (newsArticles === undefined || newsArticles.length > 0) return;
if (newsRetryRef.current >= 3) return;
const timer = setTimeout(() => {
newsRetryRef.current += 1;
refetchNews();
}, 6000);
return () => clearTimeout(timer);
}, [newsArticles]); // eslint-disable-line react-hooks/exhaustive-deps
if (isLoading) {
return <div className="text-center py-20 text-muted-foreground">Loading bill...</div>;
}
if (!bill) {
return (
<div className="text-center py-20">
<p className="text-muted-foreground">Bill not found.</p>
<Link href="/bills" className="text-sm text-primary mt-2 inline-block"> Back to bills</Link>
</div>
);
}
const label = billLabel(bill.bill_type, bill.bill_number);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<Link href="/bills" className="text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="w-4 h-4" />
</Link>
<span className="font-mono text-sm font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded">
{label}
</span>
{bill.chamber && (
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", chamberBadgeColor(bill.chamber))}>
{bill.chamber}
</span>
)}
<span className="text-sm text-muted-foreground">{congressLabel(bill.congress_number)}</span>
</div>
<h1 className="text-xl font-bold leading-snug">
{bill.short_title || bill.title || "Untitled Bill"}
</h1>
{bill.sponsor && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<User className="w-3.5 h-3.5" />
<Link href={`/members/${bill.sponsor.bioguide_id}`} className="hover:text-foreground transition-colors">
{bill.sponsor.name}
</Link>
{bill.sponsor.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(bill.sponsor.party))}>
{bill.sponsor.party}
</span>
)}
{bill.sponsor.state && <span>{bill.sponsor.state}</span>}
</div>
)}
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-3 flex-wrap">
{bill.introduced_date && (
<span>Introduced: {formatDate(bill.introduced_date)}</span>
)}
{bill.congress_url && (
<a href={bill.congress_url} target="_blank" rel="noopener noreferrer" className="hover:text-primary transition-colors">
congress.gov <ExternalLink className="w-3 h-3 inline" />
</a>
)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<CollectionPicker billId={bill.bill_id} />
<FollowButton type="bill" value={bill.bill_id} supportsModes />
</div>
</div>
{/* Content grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
<div className="md:col-span-2 space-y-4">
{/* Pinned note always visible above tabs */}
{note?.pinned && <NotesPanel billId={bill.bill_id} />}
{/* Tab bar */}
<div className="flex gap-0 border-b border-border">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
activeTab === tab.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
</button>
))}
</div>
{/* Topic tags — only show tags that have a matching topic page */}
{bill.briefs[0]?.topic_tags && bill.briefs[0].topic_tags.filter((t) => TOPIC_TAGS.has(t)).length > 0 && (
<div className="flex flex-wrap gap-1.5">
{bill.briefs[0].topic_tags.filter((t) => TOPIC_TAGS.has(t)).map((tag) => (
<Link
key={tag}
href={`/bills?topic=${encodeURIComponent(tag)}`}
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full bg-accent text-accent-foreground hover:bg-accent/70 transition-colors"
>
<Tag className="w-2.5 h-2.5" />
{TOPIC_LABEL[tag] ?? tag}
</Link>
))}
</div>
)}
{/* Tab content */}
{activeTab === "analysis" && (
<div className="space-y-6">
{bill.briefs.length > 0 ? (
<>
<BriefPanel briefs={bill.briefs} />
<DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} />
</>
) : bill.has_document ? (
<div className="bg-card border border-border rounded-lg p-6 text-center space-y-2">
<p className="text-sm font-medium text-muted-foreground">Analysis pending</p>
<p className="text-xs text-muted-foreground">
Bill text was retrieved but has not yet been analyzed. Check back shortly.
</p>
</div>
) : (
<div className="bg-card border border-border rounded-lg p-6 space-y-3">
<div className="flex items-center gap-2 text-muted-foreground">
<FileX className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium">No bill text published</span>
</div>
<p className="text-sm text-muted-foreground">
As of {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })},{" "}
no official text has been received for{" "}
<span className="font-medium">{billLabel(bill.bill_type, bill.bill_number)}</span>.
Analysis will be generated automatically once text is published on Congress.gov.
</p>
{bill.congress_url && (
<a
href={bill.congress_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Check status on Congress.gov <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
)}
</div>
)}
{activeTab === "timeline" && (
<ActionTimeline
actions={bill.actions}
latestActionDate={bill.latest_action_date}
latestActionText={bill.latest_action_text}
/>
)}
{activeTab === "votes" && (
<VotePanel billId={bill.bill_id} alwaysRender />
)}
{activeTab === "notes" && (
<NotesPanel billId={bill.bill_id} />
)}
</div>
<div className="space-y-4">
<TrendChart data={trendData} />
<NewsPanel articles={newsArticles} />
</div>
</div>
</div>
);
}

128
frontend/app/bills/page.tsx Normal file
View File

@@ -0,0 +1,128 @@
"use client";
import { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { FileText, Search } from "lucide-react";
import { useBills } from "@/lib/hooks/useBills";
import { BillCard } from "@/components/shared/BillCard";
import { TOPICS } from "@/lib/topics";
const CHAMBERS = ["", "House", "Senate"];
export default function BillsPage() {
const searchParams = useSearchParams();
const [q, setQ] = useState(searchParams.get("q") ?? "");
const [chamber, setChamber] = useState(searchParams.get("chamber") ?? "");
const [topic, setTopic] = useState(searchParams.get("topic") ?? "");
const [hasText, setHasText] = useState(true);
const [page, setPage] = useState(1);
// Sync URL params → state so tag/topic links work when already on this page
useEffect(() => {
setQ(searchParams.get("q") ?? "");
setChamber(searchParams.get("chamber") ?? "");
setTopic(searchParams.get("topic") ?? "");
setPage(1);
}, [searchParams]);
const params = {
...(q && { q }),
...(chamber && { chamber }),
...(topic && { topic }),
...(hasText && { has_document: true }),
page,
per_page: 20,
sort: "latest_action_date",
};
const { data, isLoading } = useBills(params);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Bills</h1>
<p className="text-muted-foreground text-sm mt-1">Browse and search US Congressional legislation</p>
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search bills..."
value={q}
onChange={(e) => { setQ(e.target.value); setPage(1); }}
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<select
value={chamber}
onChange={(e) => { setChamber(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
>
<option value="">All Chambers</option>
{CHAMBERS.slice(1).map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<select
value={topic}
onChange={(e) => { setTopic(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
>
<option value="">All Topics</option>
{TOPICS.map((t) => <option key={t.tag} value={t.tag}>{t.label}</option>)}
</select>
<button
onClick={() => { setHasText((v) => !v); setPage(1); }}
className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border transition-colors ${
hasText
? "bg-primary text-primary-foreground border-primary"
: "bg-card border-border text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
title="Show only bills with published text"
>
<FileText className="w-3.5 h-3.5" />
Has text
</button>
</div>
{/* Results */}
{isLoading ? (
<div className="text-center py-20 text-muted-foreground">Loading bills...</div>
) : (
<>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{data?.total ?? 0} bills found</span>
<span>Page {data?.page} of {data?.pages}</span>
</div>
<div className="space-y-3">
{data?.items?.map((bill) => (
<BillCard key={bill.bill_id} bill={bill} />
))}
</div>
{/* Pagination */}
{data && data.pages > 1 && (
<div className="flex justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 text-sm bg-card border border-border rounded-md disabled:opacity-40 hover:bg-accent transition-colors"
>
Previous
</button>
<button
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
className="px-4 py-2 text-sm bg-card border border-border rounded-md disabled:opacity-40 hover:bg-accent transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,252 @@
"use client";
import { use, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { ArrowLeft, Check, Copy, Globe, Lock, Minus, Search, X } from "lucide-react";
import { collectionsAPI, billsAPI } from "@/lib/api";
import type { Bill } from "@/lib/types";
import { billLabel, formatDate } from "@/lib/utils";
export default function CollectionDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const collectionId = parseInt(id, 10);
const qc = useQueryClient();
const [editingName, setEditingName] = useState(false);
const [nameInput, setNameInput] = useState("");
const [copied, setCopied] = useState(false);
const [searchQ, setSearchQ] = useState("");
const [searchResults, setSearchResults] = useState<Bill[]>([]);
const [searching, setSearching] = useState(false);
const { data: collection, isLoading } = useQuery({
queryKey: ["collection", collectionId],
queryFn: () => collectionsAPI.get(collectionId),
});
const updateMutation = useMutation({
mutationFn: (data: { name?: string; is_public?: boolean }) =>
collectionsAPI.update(collectionId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["collection", collectionId] });
qc.invalidateQueries({ queryKey: ["collections"] });
setEditingName(false);
},
});
const addBillMutation = useMutation({
mutationFn: (bill_id: string) => collectionsAPI.addBill(collectionId, bill_id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["collection", collectionId] });
qc.invalidateQueries({ queryKey: ["collections"] });
},
});
const removeBillMutation = useMutation({
mutationFn: (bill_id: string) => collectionsAPI.removeBill(collectionId, bill_id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["collection", collectionId] });
qc.invalidateQueries({ queryKey: ["collections"] });
},
});
async function handleSearch(q: string) {
setSearchQ(q);
if (!q.trim()) { setSearchResults([]); return; }
setSearching(true);
try {
const res = await billsAPI.list({ q, per_page: 8 });
setSearchResults(res.items);
} finally {
setSearching(false);
}
}
function copyShareLink() {
if (!collection) return;
navigator.clipboard.writeText(`${window.location.origin}/share/collection/${collection.share_token}`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function startRename() {
setNameInput(collection?.name ?? "");
setEditingName(true);
}
function submitRename(e: React.FormEvent) {
e.preventDefault();
const name = nameInput.trim();
if (!name || name === collection?.name) { setEditingName(false); return; }
updateMutation.mutate({ name });
}
if (isLoading) {
return <div className="text-center py-20 text-muted-foreground text-sm">Loading</div>;
}
if (!collection) {
return (
<div className="text-center py-20">
<p className="text-muted-foreground">Collection not found.</p>
<Link href="/collections" className="text-sm text-primary mt-2 inline-block"> Back to collections</Link>
</div>
);
}
const collectionBillIds = new Set(collection.bills.map((b) => b.bill_id));
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<Link href="/collections" className="text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="w-4 h-4" />
</Link>
{editingName ? (
<form onSubmit={submitRename} className="flex items-center gap-2 flex-1">
<input
type="text"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
maxLength={100}
autoFocus
className="flex-1 px-2 py-1 text-lg font-bold bg-background border-b-2 border-primary focus:outline-none"
/>
<button type="submit" className="p-1 text-primary hover:opacity-70">
<Check className="w-4 h-4" />
</button>
<button type="button" onClick={() => setEditingName(false)} className="p-1 text-muted-foreground hover:opacity-70">
<X className="w-4 h-4" />
</button>
</form>
) : (
<button
onClick={startRename}
className="text-xl font-bold hover:opacity-70 transition-opacity text-left"
title="Click to rename"
>
{collection.name}
</button>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Public/private toggle */}
<button
onClick={() => updateMutation.mutate({ is_public: !collection.is_public })}
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border border-border hover:bg-accent transition-colors"
>
{collection.is_public ? (
<><Globe className="w-3 h-3 text-green-500" /> Public</>
) : (
<><Lock className="w-3 h-3 text-muted-foreground" /> Private</>
)}
</button>
{/* Copy share link */}
<button
onClick={copyShareLink}
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border border-border hover:bg-accent transition-colors"
>
{copied ? (
<><Check className="w-3 h-3 text-green-500" /> Link copied!</>
) : (
<><Copy className="w-3 h-3" /> Copy share link</>
)}
</button>
<span className="text-xs text-muted-foreground">
{collection.bill_count} {collection.bill_count === 1 ? "bill" : "bills"}
</span>
</div>
</div>
{/* Add bills search */}
<div className="relative">
<div className="flex items-center gap-2 px-3 py-2 bg-card border border-border rounded-lg">
<Search className="w-4 h-4 text-muted-foreground shrink-0" />
<input
type="text"
value={searchQ}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search to add bills…"
className="flex-1 text-sm bg-transparent focus:outline-none"
/>
{searching && <span className="text-xs text-muted-foreground">Searching</span>}
</div>
{searchResults.length > 0 && searchQ && (
<div className="absolute top-full left-0 right-0 z-10 mt-1 bg-card border border-border rounded-lg shadow-lg overflow-hidden">
{searchResults.map((bill) => {
const inCollection = collectionBillIds.has(bill.bill_id);
return (
<button
key={bill.bill_id}
onClick={() => {
if (!inCollection) {
addBillMutation.mutate(bill.bill_id);
setSearchQ("");
setSearchResults([]);
}
}}
disabled={inCollection}
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-default"
>
<span className="font-mono text-xs text-muted-foreground shrink-0">
{billLabel(bill.bill_type, bill.bill_number)}
</span>
<span className="text-sm truncate flex-1">
{bill.short_title || bill.title || "Untitled"}
</span>
{inCollection && (
<span className="text-xs text-muted-foreground shrink-0">Added</span>
)}
</button>
);
})}
</div>
)}
</div>
{/* Bill list */}
{collection.bills.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p className="text-sm">No bills yet search to add some.</p>
</div>
) : (
<div className="space-y-1">
{collection.bills.map((bill) => (
<div
key={bill.bill_id}
className="flex items-center gap-3 px-4 py-3 bg-card border border-border rounded-lg group"
>
<Link href={`/bills/${bill.bill_id}`} className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground shrink-0">
{billLabel(bill.bill_type, bill.bill_number)}
</span>
<span className="text-sm font-medium truncate">
{bill.short_title || bill.title || "Untitled"}
</span>
</div>
{bill.latest_action_date && (
<p className="text-xs text-muted-foreground mt-0.5">
Latest action: {formatDate(bill.latest_action_date)}
</p>
)}
</Link>
<button
onClick={() => removeBillMutation.mutate(bill.bill_id)}
className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
title="Remove from collection"
>
<Minus className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Bookmark, Plus, Globe, Lock, Trash2 } from "lucide-react";
import { collectionsAPI } from "@/lib/api";
import { HelpTip } from "@/components/shared/HelpTip";
import type { Collection } from "@/lib/types";
export default function CollectionsPage() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [newName, setNewName] = useState("");
const [newPublic, setNewPublic] = useState(false);
const [formError, setFormError] = useState("");
const { data: collections, isLoading } = useQuery({
queryKey: ["collections"],
queryFn: collectionsAPI.list,
});
const createMutation = useMutation({
mutationFn: ({ name, is_public }: { name: string; is_public: boolean }) =>
collectionsAPI.create(name, is_public),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["collections"] });
setNewName("");
setNewPublic(false);
setShowForm(false);
setFormError("");
},
onError: () => setFormError("Failed to create collection. Try again."),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => collectionsAPI.delete(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["collections"] }),
});
function handleCreate(e: React.FormEvent) {
e.preventDefault();
const name = newName.trim();
if (!name) { setFormError("Name is required"); return; }
if (name.length > 100) { setFormError("Name must be ≤ 100 characters"); return; }
setFormError("");
createMutation.mutate({ name, is_public: newPublic });
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Bookmark className="w-5 h-5 text-primary" />
<h1 className="text-xl font-bold">My Collections</h1>
</div>
<p className="text-sm text-muted-foreground mt-1">
A collection is a named group of bills you curate like a playlist for legislation.
Share any collection via a link; no account required to view.
</p>
</div>
<button
onClick={() => setShowForm((v) => !v)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
New Collection
</button>
</div>
{showForm && (
<form
onSubmit={handleCreate}
className="bg-card border border-border rounded-lg p-4 space-y-3"
>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Collection name</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Healthcare Watch"
maxLength={100}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
autoFocus
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={newPublic}
onChange={(e) => setNewPublic(e.target.checked)}
className="rounded"
/>
<span>Public collection</span>
<HelpTip content="Share links work whether or not a collection is public. Marking it public signals it may appear in a future public directory — private collections are invisible to anyone without your link." />
</label>
{formError && <p className="text-xs text-destructive">{formError}</p>}
<div className="flex gap-2">
<button
type="submit"
disabled={createMutation.isPending}
className="px-3 py-1.5 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{createMutation.isPending ? "Creating…" : "Create"}
</button>
<button
type="button"
onClick={() => { setShowForm(false); setFormError(""); setNewName(""); }}
className="px-3 py-1.5 text-sm text-muted-foreground rounded-md hover:bg-accent transition-colors"
>
Cancel
</button>
</div>
</form>
)}
{isLoading ? (
<div className="text-center py-12 text-muted-foreground text-sm">Loading collections</div>
) : !collections || collections.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Bookmark className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">No collections yet create one to start grouping bills.</p>
</div>
) : (
<div className="space-y-2">
{collections.map((c: Collection) => (
<div
key={c.id}
className="bg-card border border-border rounded-lg px-4 py-3 flex items-center gap-3 group"
>
<Link href={`/collections/${c.id}`} className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{c.name}</span>
<span className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded shrink-0">
{c.bill_count} {c.bill_count === 1 ? "bill" : "bills"}
</span>
{c.is_public ? (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
<Globe className="w-3 h-3" /> Public
</span>
) : (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Lock className="w-3 h-3" /> Private
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Created {new Date(c.created_at).toLocaleDateString()}
</p>
</Link>
<button
onClick={() => {
if (confirm(`Delete "${c.name}"? This cannot be undone.`)) {
deleteMutation.mutate(c.id);
}
}}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
title="Delete collection"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,330 @@
"use client";
import { useState } from "react";
import { useQueries } from "@tanstack/react-query";
import Link from "next/link";
import { ChevronDown, ChevronRight, ExternalLink, Heart, Search, X } from "lucide-react";
import { useFollows, useRemoveFollow } from "@/lib/hooks/useFollows";
import { billsAPI, membersAPI } from "@/lib/api";
import { FollowButton } from "@/components/shared/FollowButton";
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor } from "@/lib/utils";
import type { Follow } from "@/lib/types";
// ── Bill row ─────────────────────────────────────────────────────────────────
function BillRow({ follow, bill }: { follow: Follow; bill?: ReturnType<typeof billsAPI.get> extends Promise<infer T> ? T : never }) {
const label = bill ? billLabel(bill.bill_type, bill.bill_number) : follow.follow_value;
return (
<div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-mono font-semibold text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{label}
</span>
{bill?.chamber && (
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", chamberBadgeColor(bill.chamber))}>
{bill.chamber}
</span>
)}
</div>
<Link
href={`/bills/${follow.follow_value}`}
className="text-sm font-medium hover:text-primary transition-colors line-clamp-2 leading-snug"
>
{bill ? (bill.short_title || bill.title || label) : <span className="text-muted-foreground">Loading</span>}
</Link>
{bill?.latest_action_text && (
<p className="text-xs text-muted-foreground mt-1.5 line-clamp-1">
{bill.latest_action_date && <span>{formatDate(bill.latest_action_date)} </span>}
{bill.latest_action_text}
</p>
)}
</div>
<FollowButton type="bill" value={follow.follow_value} supportsModes />
</div>
);
}
// ── Member row ────────────────────────────────────────────────────────────────
function MemberRow({ follow, member, onRemove }: {
follow: Follow;
member?: ReturnType<typeof membersAPI.get> extends Promise<infer T> ? T : never;
onRemove: () => void;
}) {
return (
<div className="bg-card border border-border rounded-lg p-4 flex items-center gap-4">
<div className="shrink-0">
{member?.photo_url ? (
<img src={member.photo_url} alt={member.name} className="w-12 h-12 rounded-full object-cover border border-border" />
) : (
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center text-lg font-semibold text-muted-foreground">
{member ? member.name[0] : "?"}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link href={`/members/${follow.follow_value}`} className="text-sm font-semibold hover:text-primary transition-colors">
{member?.name ?? follow.follow_value}
</Link>
{member?.party && (
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
</div>
{(member?.chamber || member?.state || member?.district) && (
<p className="text-xs text-muted-foreground mt-0.5">
{[member.chamber, member.state, member.district ? `District ${member.district}` : null]
.filter(Boolean).join(" · ")}
</p>
)}
{member?.official_url && (
<a href={member.official_url} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1">
Official site <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
<button onClick={onRemove} className="text-muted-foreground hover:text-destructive transition-colors p-1 shrink-0" title="Unfollow">
<X className="w-4 h-4" />
</button>
</div>
);
}
// ── Section accordion wrapper ─────────────────────────────────────────────────
function Section({ title, count, children }: { title: string; count: number; children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-2 mb-3 group"
>
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
<span className="font-semibold">{title}</span>
<span className="text-xs text-muted-foreground font-normal">({count})</span>
</button>
{open && children}
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function FollowingPage() {
const { data: follows = [], isLoading } = useFollows();
const remove = useRemoveFollow();
const [billSearch, setBillSearch] = useState("");
const [billChamber, setBillChamber] = useState("");
const [memberSearch, setMemberSearch] = useState("");
const [memberParty, setMemberParty] = useState("");
const [topicSearch, setTopicSearch] = useState("");
const bills = follows.filter((f) => f.follow_type === "bill");
const members = follows.filter((f) => f.follow_type === "member");
const topics = follows.filter((f) => f.follow_type === "topic");
// Batch-fetch bill + member data at page level so filters have access to titles/names.
// Uses the same query keys as BillRow/MemberRow — React Query deduplicates, no extra calls.
const billQueries = useQueries({
queries: bills.map((f) => ({
queryKey: ["bill", f.follow_value],
queryFn: () => billsAPI.get(f.follow_value),
staleTime: 2 * 60 * 1000,
})),
});
const memberQueries = useQueries({
queries: members.map((f) => ({
queryKey: ["member", f.follow_value],
queryFn: () => membersAPI.get(f.follow_value),
staleTime: 10 * 60 * 1000,
})),
});
// Filter bills
const filteredBills = bills.filter((f, i) => {
const bill = billQueries[i]?.data;
if (billChamber && bill?.chamber?.toLowerCase() !== billChamber.toLowerCase()) return false;
if (billSearch) {
const q = billSearch.toLowerCase();
const label = bill ? billLabel(bill.bill_type, bill.bill_number).toLowerCase() : "";
const title = (bill?.short_title || bill?.title || "").toLowerCase();
const id = f.follow_value.toLowerCase();
if (!label.includes(q) && !title.includes(q) && !id.includes(q)) return false;
}
return true;
});
// Filter members
const filteredMembers = members.filter((f, i) => {
const member = memberQueries[i]?.data;
if (memberParty && member?.party !== memberParty) return false;
if (memberSearch) {
const q = memberSearch.toLowerCase();
const name = (member?.name || f.follow_value).toLowerCase();
if (!name.includes(q)) return false;
}
return true;
});
// Filter topics
const filteredTopics = topics.filter((f) =>
!topicSearch || f.follow_value.toLowerCase().includes(topicSearch.toLowerCase())
);
// Unique parties and chambers from loaded data for filter dropdowns
const loadedChambers = [...new Set(billQueries.map((q) => q.data?.chamber).filter(Boolean))] as string[];
const loadedParties = [...new Set(memberQueries.map((q) => q.data?.party).filter(Boolean))] as string[];
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Heart className="w-5 h-5" /> Following
</h1>
<p className="text-muted-foreground text-sm mt-1">Manage what you follow</p>
</div>
{/* Bills */}
<Section title="Bills" count={bills.length}>
<div className="space-y-3">
{/* Search + filter bar */}
{bills.length > 0 && (
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
type="text"
placeholder="Search bills…"
value={billSearch}
onChange={(e) => setBillSearch(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{loadedChambers.length > 1 && (
<select
value={billChamber}
onChange={(e) => setBillChamber(e.target.value)}
className="px-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none"
>
<option value="">All Chambers</option>
{loadedChambers.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
)}
</div>
)}
{!bills.length ? (
<p className="text-sm text-muted-foreground">No bills followed yet.</p>
) : !filteredBills.length ? (
<p className="text-sm text-muted-foreground">No bills match your filters.</p>
) : (
filteredBills.map((f, i) => {
const originalIndex = bills.indexOf(f);
return <BillRow key={f.id} follow={f} bill={billQueries[originalIndex]?.data} />;
})
)}
</div>
</Section>
{/* Members */}
<Section title="Members" count={members.length}>
<div className="space-y-3">
{members.length > 0 && (
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
type="text"
placeholder="Search members…"
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{loadedParties.length > 1 && (
<select
value={memberParty}
onChange={(e) => setMemberParty(e.target.value)}
className="px-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none"
>
<option value="">All Parties</option>
{loadedParties.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
)}
</div>
)}
{!members.length ? (
<p className="text-sm text-muted-foreground">No members followed yet.</p>
) : !filteredMembers.length ? (
<p className="text-sm text-muted-foreground">No members match your filters.</p>
) : (
filteredMembers.map((f, i) => {
const originalIndex = members.indexOf(f);
return (
<MemberRow
key={f.id}
follow={f}
member={memberQueries[originalIndex]?.data}
onRemove={() => remove.mutate(f.id)}
/>
);
})
)}
</div>
</Section>
{/* Topics */}
<Section title="Topics" count={topics.length}>
<div className="space-y-3">
{topics.length > 0 && (
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
type="text"
placeholder="Search topics…"
value={topicSearch}
onChange={(e) => setTopicSearch(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
)}
{!topics.length ? (
<p className="text-sm text-muted-foreground">No topics followed yet.</p>
) : !filteredTopics.length ? (
<p className="text-sm text-muted-foreground">No topics match your search.</p>
) : (
<div className="space-y-2">
{filteredTopics.map((f) => (
<div key={f.id} className="bg-card border border-border rounded-lg p-3 flex items-center justify-between">
<Link
href={`/bills?topic=${f.follow_value}`}
className="text-sm font-medium hover:text-primary transition-colors capitalize"
>
{f.follow_value.replace(/-/g, " ")}
</Link>
<button
onClick={() => remove.mutate(f.id)}
className="text-muted-foreground hover:text-destructive transition-colors p-1"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
</Section>
</div>
);
}

55
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,55 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 0.5rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 6%;
--card-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,353 @@
import Link from "next/link";
import {
BarChart2,
Bell,
Bookmark,
Calendar,
Clock,
FileText,
Filter,
Heart,
HelpCircle,
ListChecks,
Mail,
MessageSquare,
Rss,
Shield,
Share2,
StickyNote,
TrendingUp,
Users,
Zap,
} from "lucide-react";
function Section({ id, title, icon: Icon, children }: {
id: string;
title: string;
icon: React.ElementType;
children: React.ReactNode;
}) {
return (
<section id={id} className="bg-card border border-border rounded-lg p-6 space-y-4 scroll-mt-6">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Icon className="w-5 h-5 text-primary" />
{title}
</h2>
{children}
</section>
);
}
function Item({ icon: Icon, color, title, children }: {
icon: React.ElementType;
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<div className="flex gap-3">
<div className={`mt-0.5 shrink-0 w-7 h-7 rounded-full flex items-center justify-center ${color}`}>
<Icon className="w-3.5 h-3.5" />
</div>
<div>
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{children}</p>
</div>
</div>
);
}
export default function HowItWorksPage() {
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<HelpCircle className="w-5 h-5" /> How it works
</h1>
<p className="text-muted-foreground text-sm mt-1">
A quick guide to PocketVeto&apos;s features.
</p>
{/* Jump links */}
<div className="flex flex-wrap gap-2 mt-3">
{[
{ href: "#follow", label: "Following" },
{ href: "#collections", label: "Collections" },
{ href: "#notifications", label: "Notifications" },
{ href: "#briefs", label: "AI Briefs" },
{ href: "#votes", label: "Votes" },
{ href: "#alignment", label: "Alignment" },
{ href: "#notes", label: "Notes" },
{ href: "#bills", label: "Bills" },
{ href: "#members-topics", label: "Members & Topics" },
{ href: "#dashboard", label: "Dashboard" },
].map(({ href, label }) => (
<a
key={href}
href={href}
className="text-xs px-2.5 py-1 bg-muted rounded-full hover:bg-accent transition-colors"
>
{label}
</a>
))}
</div>
</div>
{/* Following */}
<Section id="follow" title="Following bills" icon={Heart}>
<p className="text-sm text-muted-foreground">
Follow any bill to track it. PocketVeto checks for changes and notifies you through your
configured channels. Three modes let you tune the signal to your interest level each
with its own independent set of alert filters.
</p>
<div className="space-y-3">
<Item icon={Heart} color="bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400" title="Follow">
The standard mode. Default alerts: new bill text, amendments filed, chamber votes,
presidential action, and committee reports.
</Item>
<Item icon={Shield} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Pocket Veto">
For bills you oppose and only want to hear about if they gain real traction. Default
alerts: chamber votes and presidential action only no noise from early committee or
document activity.
</Item>
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Pocket Boost">
For bills you actively support. Default alerts: everything new text, amendments,
votes, presidential action, committee reports, calendar placement, procedural moves,
and committee referrals. Also adds &ldquo;Find Your Rep&rdquo; action buttons to push
notifications.
</Item>
</div>
<div className="rounded-md bg-muted/60 px-4 py-3 space-y-1.5">
<p className="text-xs font-medium flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5" /> Adjusting alert filters
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
The defaults above are starting points. In{" "}
<Link href="/notifications" className="text-primary hover:underline">Notifications Alert Filters</Link>,
each mode has its own tab with eight independently toggleable alert types. For example,
a Follow bill where you don&apos;t care about committee reports uncheck it and only
that mode is affected. Hit <strong>Load defaults</strong> on any tab to revert to the
preset above.
</p>
</div>
<p className="text-xs text-muted-foreground">
You can also follow <strong>members</strong> and <strong>topics</strong>.
When a followed member sponsors a bill, or a new bill matches a followed topic, you&apos;ll
receive a <em>Discovery</em> alert. These have their own independent filter set in{" "}
<Link href="/notifications" className="text-primary hover:underline">Notifications Alert Filters Discovery</Link>.
By default, all followed members and topics trigger notifications you can mute individual
ones without unfollowing them.
</p>
</Section>
{/* Collections */}
<Section id="collections" title="Collections" icon={Bookmark}>
<p className="text-sm text-muted-foreground">
A collection is a named, curated group of bills like a playlist for legislation. Use
collections to track a policy area, build a watchlist for an advocacy campaign, or share
research with colleagues.
</p>
<div className="space-y-3">
<Item icon={Bookmark} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Creating a collection">
Give it a name (e.g. &ldquo;Healthcare Watch&rdquo;) and add bills from any bill detail
page using the bookmark icon next to the Follow button.
</Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing">
Every collection has a unique share link. Anyone with the link can view the collection
no account required.
</Item>
</div>
<p className="text-xs text-muted-foreground">
<strong>Public vs. private:</strong> Both have share links. Marking a collection public
signals it may appear in a future public directory; private collections are invisible to
anyone without your link.
</p>
</Section>
{/* Notifications */}
<Section id="notifications" title="Notifications" icon={Bell}>
<p className="text-sm text-muted-foreground">
PocketVeto delivers alerts through three independent channels use any combination.
</p>
<div className="space-y-3">
<Item icon={Bell} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Push via ntfy">
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a>
{" "}is a free, open-source push notification service. Configure a topic URL in{" "}
<Link href="/notifications" className="text-primary hover:underline">Notifications</Link>{" "}
and receive real-time alerts on any device with the ntfy app.
</Item>
<Item icon={Mail} color="bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400" title="Email">
Receive alerts as plain-text emails. Add your address in{" "}
<Link href="/notifications" className="text-primary hover:underline">Notifications Email</Link>.
Every email includes a one-click unsubscribe link, and your address is never used for
anything other than bill alerts.
</Item>
<Item icon={Rss} color="bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400" title="RSS feed">
A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader
(Feedly, NetNewsWire, etc.). Always real-time, completely independent of the other channels.
</Item>
<Item icon={Clock} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Quiet hours">
Pause push and email notifications during set hours (e.g. 10 PM 8 AM). Events that
arrive during quiet hours are queued and sent as a batch when the window ends.
</Item>
<Item icon={Calendar} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Digest mode">
Instead of one alert per event, receive a single bundled summary on a daily or weekly
schedule. Your RSS feed is always real-time regardless of this setting.
</Item>
<Item icon={MessageSquare} color="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" title="Discovery alerts">
Member and topic follows generate Discovery alerts separate from the bills you follow
directly. In{" "}
<Link href="/notifications" className="text-primary hover:underline">Alert Filters Discovery</Link>,
you can enable or disable these independently, tune which event types trigger them, and
mute specific members or topics without unfollowing them. Each notification includes a
&ldquo;why&rdquo; line so you always know which follow triggered it.
</Item>
</div>
</Section>
{/* AI Briefs */}
<Section id="briefs" title="AI Briefs" icon={FileText}>
<p className="text-sm text-muted-foreground">
For bills with published official text, PocketVeto generates a plain-English AI brief
automatically no action needed on your part.
</p>
<div className="space-y-3">
<Item icon={FileText} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="What's in a brief">
A plain-English summary, key policy points with references to specific bill sections
(§ chips you can expand to see the quoted source text), and a risks section that flags
potential unintended consequences or contested provisions.
</Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing a brief">
Click the share icon in the brief panel to copy a public link. Anyone can read the
brief at that URL no login required.
</Item>
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Draft a letter">
Use the Draft Letter panel in the Analysis tab to generate a personalised letter to
your representative based on the brief&apos;s key points.
</Item>
</div>
<p className="text-xs text-muted-foreground">
Briefs are only generated for bills where GovInfo has published official text. Bills
without text show a &ldquo;No text&rdquo; badge on their card. When a bill is amended,
a new &ldquo;What Changed&rdquo; brief is generated automatically alongside the original.
</p>
</Section>
{/* Votes */}
<Section id="votes" title="Roll-call votes" icon={ListChecks}>
<p className="text-sm text-muted-foreground">
The <strong>Votes</strong> tab on any bill page shows every recorded roll-call vote for
that bill, fetched directly from official House and Senate XML sources.
</p>
<div className="space-y-3">
<Item icon={ListChecks} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Vote breakdown">
Each vote shows the result, chamber, roll number, date, and a visual Yea/Nay bar with
exact counts.
</Item>
<Item icon={Users} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Followed member positions">
If any of your followed members voted on the bill, their individual Yea/Nay positions
are surfaced directly in the vote row no need to dig through the full member list.
</Item>
</div>
</Section>
{/* Alignment */}
<Section id="alignment" title="Representation Alignment" icon={BarChart2}>
<p className="text-sm text-muted-foreground">
The <Link href="/alignment" className="text-primary hover:underline">Alignment</Link> page
shows how often your followed members vote in line with your stated bill positions.
</p>
<div className="space-y-3">
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="How it's calculated">
For every bill you follow with Pocket Boost or Pocket Veto, PocketVeto checks how each
of your followed members voted. A Yea on a boosted bill counts as aligned; a Nay on a
vetoed bill counts as aligned. Not Voting and Present are excluded.
</Item>
<Item icon={BarChart2} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Alignment score">
Each followed member gets an alignment percentage based on all overlapping votes. Members
are ranked from most to least aligned with your positions.
</Item>
</div>
<p className="text-xs text-muted-foreground">
Alignment only appears for members who have actually voted on bills you&apos;ve stanced.
Follow more members and stake positions on more bills to build a fuller picture.
</p>
</Section>
{/* Notes */}
<Section id="notes" title="Notes" icon={StickyNote}>
<p className="text-sm text-muted-foreground">
Add a personal note to any bill visible only to you. Find it in the{" "}
<strong>Notes</strong> tab on any bill detail page.
</p>
<div className="space-y-3">
<Item icon={StickyNote} color="bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" title="Pinning">
Pin a note to float it above the tab bar so it&apos;s always visible when you open the
bill, regardless of which tab you&apos;re on.
</Item>
</div>
</Section>
{/* Bills */}
<Section id="bills" title="Browsing bills" icon={FileText}>
<p className="text-sm text-muted-foreground">
The <Link href="/bills" className="text-primary hover:underline">Bills</Link> page lists
all tracked legislation. Use the filters to narrow your search.
</p>
<div className="space-y-2 text-xs text-muted-foreground">
<p><strong className="text-foreground">Search</strong> matches bill ID, title, and short title.</p>
<p><strong className="text-foreground">Chamber</strong> House or Senate.</p>
<p><strong className="text-foreground">Topic</strong> AI-tagged policy area (healthcare, defense, etc.).</p>
<p><strong className="text-foreground">Has text</strong> show only bills with published official text. On by default.</p>
</div>
<p className="text-xs text-muted-foreground">
Each bill page is organised into four tabs: <strong>Analysis</strong> (AI brief + draft
letter), <strong>Timeline</strong> (action history), <strong>Votes</strong> (roll-call
records), and <strong>Notes</strong> (your personal note).
Topic tags appear just below the tab bar click any tag to jump to that filtered view.
</p>
</Section>
{/* Members & Topics */}
<Section id="members-topics" title="Members & Topics" icon={Users}>
<p className="text-sm text-muted-foreground">
Browse and follow legislators and policy topics independently of specific bills.
</p>
<div className="space-y-3">
<Item icon={Users} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Members">
The <Link href="/members" className="text-primary hover:underline">Members</Link> page
lists all current members of Congress. Each member page shows their sponsored bills,
news coverage, voting trend, and once enough votes are recorded
an <strong>effectiveness score</strong> ranking how often their sponsored bills advance.
</Item>
<Item icon={Filter} color="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" title="Topics">
The <Link href="/topics" className="text-primary hover:underline">Topics</Link> page
lists all AI-tagged policy areas. Following a topic sends you a Discovery alert whenever
a new bill is tagged with it useful for staying on top of a policy area without
tracking individual bills.
</Item>
</div>
</Section>
{/* Dashboard */}
<Section id="dashboard" title="Dashboard" icon={TrendingUp}>
<p className="text-sm text-muted-foreground">
The <Link href="/" className="text-primary hover:underline">Dashboard</Link> is your
personalised home view, split into two areas.
</p>
<div className="space-y-3">
<Item icon={Heart} color="bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400" title="Your feed">
Bills from your follows directly followed bills, bills sponsored by followed members,
and bills matching followed topics sorted by latest activity.
</Item>
<Item icon={TrendingUp} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Trending">
The top 10 bills by composite trend score, calculated nightly from news article volume
(NewsAPI + Google News) and Google Trends interest. A bill climbing here is getting real
public attention regardless of whether you follow it.
</Item>
</div>
</Section>
</div>
);
}

11
frontend/app/icon.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1e40af"/>
<g stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none">
<line x1="4" y1="28" x2="28" y2="28"/>
<line x1="8" y1="24" x2="8" y2="15"/>
<line x1="13" y1="24" x2="13" y2="15"/>
<line x1="19" y1="24" x2="19" y2="15"/>
<line x1="24" y1="24" x2="24" y2="15"/>
<polygon points="16,5 27,13 5,13"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 488 B

30
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import { AuthGuard } from "@/components/shared/AuthGuard";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "PocketVeto",
description: "Monitor US Congress with AI-powered bill summaries and trend analysis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
<AuthGuard>
{children}
</AuthGuard>
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { authAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
export default function LoginPage() {
const router = useRouter();
const setAuth = useAuthStore((s) => s.setAuth);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const { access_token, user } = await authAPI.login(email.trim(), password);
setAuth(access_token, { id: user.id, email: user.email, is_admin: user.is_admin });
router.replace("/");
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
"Login failed. Check your email and password.";
setError(msg);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-full max-w-sm space-y-6 p-8 border rounded-lg bg-card shadow-sm">
<div>
<h1 className="text-2xl font-bold">PocketVeto</h1>
<p className="text-muted-foreground text-sm mt-1">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">Email</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">Password</label>
<input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
<p className="text-sm text-center text-muted-foreground">
No account?{" "}
<Link href="/register" className="text-primary hover:underline">
Register
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
"use client";
import { use } from "react";
import Link from "next/link";
import Image from "next/image";
import {
ArrowLeft,
ExternalLink,
MapPin,
Phone,
Globe,
Star,
FileText,
Users,
} from "lucide-react";
import { useMember, useMemberBills, useMemberTrend, useMemberNews } from "@/lib/hooks/useMembers";
import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton";
import { BillCard } from "@/components/shared/BillCard";
import { cn, partyBadgeColor } from "@/lib/utils";
function ordinal(n: number) {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
export default function MemberDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { data: member, isLoading } = useMember(id);
const { data: billsData } = useMemberBills(id);
const { data: trendData } = useMemberTrend(id, 30);
const { data: newsData } = useMemberNews(id);
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
if (!member) return <div className="text-center py-20 text-muted-foreground">Member not found.</div>;
const currentLeadership = member.leadership_json?.filter((l) => l.current);
const termsSorted = [...(member.terms_json || [])].reverse();
return (
<div className="space-y-6">
{/* Back */}
<Link href="/members" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="w-4 h-4" />
Members
</Link>
{/* Bio header */}
<div className="bg-card border border-border rounded-lg p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-5">
{member.photo_url ? (
<Image
src={member.photo_url}
alt={member.name}
width={96}
height={96}
className="rounded-lg object-cover shrink-0 border border-border"
/>
) : (
<div className="w-24 h-24 rounded-lg bg-muted flex items-center justify-center shrink-0 border border-border">
<Users className="w-8 h-8 text-muted-foreground" />
</div>
)}
<div className="space-y-2">
<div>
<h1 className="text-2xl font-bold">{member.name}</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{member.party && (
<span className={cn("px-2 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.chamber && <span className="text-sm text-muted-foreground">{member.chamber}</span>}
{member.state && <span className="text-sm text-muted-foreground">{member.state}</span>}
{member.district && <span className="text-sm text-muted-foreground">District {member.district}</span>}
{member.birth_year && (
<span className="text-sm text-muted-foreground">b. {member.birth_year}</span>
)}
</div>
</div>
{/* Leadership */}
{currentLeadership && currentLeadership.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{currentLeadership.map((l, i) => (
<span key={i} className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 bg-primary/10 text-primary rounded-full">
<Star className="w-3 h-3" />
{l.type}
</span>
))}
</div>
)}
{/* Contact */}
<div className="flex flex-col gap-1.5 text-sm text-muted-foreground">
{member.address && (
<div className="flex items-start gap-1.5">
<MapPin className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<span>{member.address}</span>
</div>
)}
{member.phone && (
<div className="flex items-center gap-1.5">
<Phone className="w-3.5 h-3.5 shrink-0" />
<a href={`tel:${member.phone}`} className="hover:text-foreground transition-colors">
{member.phone}
</a>
</div>
)}
{member.official_url && (
<div className="flex items-center gap-1.5">
<Globe className="w-3.5 h-3.5 shrink-0" />
<a href={member.official_url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors truncate max-w-xs">
{member.official_url.replace(/^https?:\/\//, "")}
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</div>
)}
{member.congress_url && (
<div className="flex items-center gap-1.5">
<ExternalLink className="w-3.5 h-3.5 shrink-0" />
<a href={member.congress_url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
congress.gov profile
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</div>
)}
</div>
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left column */}
<div className="md:col-span-2 space-y-6">
{/* Sponsored bills */}
<div>
<h2 className="font-semibold mb-4 flex items-center gap-2">
<FileText className="w-4 h-4" />
Sponsored Bills
{billsData?.total != null && (
<span className="text-xs text-muted-foreground font-normal">({billsData.total})</span>
)}
</h2>
{!billsData?.items?.length ? (
<p className="text-sm text-muted-foreground">No bills found.</p>
) : (
<div className="space-y-3">
{billsData.items.map((bill) => <BillCard key={bill.bill_id} bill={bill} />)}
</div>
)}
</div>
</div>
{/* Right column */}
<div className="space-y-4">
{/* Public Interest */}
<TrendChart data={trendData ?? []} title="Public Interest" />
{/* News */}
<NewsPanel articles={newsData} />
{/* Legislation stats */}
{(member.sponsored_count != null || member.cosponsored_count != null) && (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Legislation</h3>
<div className="space-y-2">
{member.sponsored_count != null && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Sponsored</span>
<span className="font-medium">{member.sponsored_count.toLocaleString()}</span>
</div>
)}
{member.cosponsored_count != null && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Cosponsored</span>
<span className="font-medium">{member.cosponsored_count.toLocaleString()}</span>
</div>
)}
</div>
</div>
)}
{/* Effectiveness Score */}
{member.effectiveness_score != null && (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Effectiveness Score</h3>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Score</span>
<span className="font-medium">{member.effectiveness_score.toFixed(1)}</span>
</div>
{member.effectiveness_percentile != null && (
<>
<div className="h-1.5 bg-muted rounded overflow-hidden">
<div
className={`h-full rounded transition-all ${
member.effectiveness_percentile >= 66
? "bg-emerald-500"
: member.effectiveness_percentile >= 33
? "bg-amber-500"
: "bg-red-500"
}`}
style={{ width: `${member.effectiveness_percentile}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{Math.round(member.effectiveness_percentile)}th percentile
{member.effectiveness_tier ? ` among ${member.effectiveness_tier} members` : ""}
</p>
</>
)}
<p className="text-xs text-muted-foreground leading-relaxed">
Measures legislative output: how far sponsored bills travel, bipartisan support, substance, and committee leadership.
</p>
</div>
</div>
)}
{/* Service history */}
{termsSorted.length > 0 && (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Service History</h3>
<div className="space-y-2">
{termsSorted.map((term, i) => (
<div key={i} className="text-sm border-l-2 border-border pl-3">
<div className="font-medium">
{term.congress ? `${ordinal(term.congress)} Congress` : ""}
{term.startYear && term.endYear
? ` (${term.startYear}${term.endYear})`
: term.startYear
? ` (${term.startYear}present)`
: ""}
</div>
<div className="text-xs text-muted-foreground">
{[term.chamber, term.partyName, term.stateName].filter(Boolean).join(" · ")}
{term.district ? ` · District ${term.district}` : ""}
</div>
</div>
))}
</div>
</div>
)}
{/* All leadership roles */}
{member.leadership_json && member.leadership_json.length > 0 && (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold">Leadership Roles</h3>
<div className="space-y-2">
{member.leadership_json.map((l, i) => (
<div key={i} className="flex items-start justify-between gap-2 text-sm">
<span className={l.current ? "font-medium" : "text-muted-foreground"}>{l.type}</span>
<span className="text-xs text-muted-foreground shrink-0">
{l.congress ? `${ordinal(l.congress)}` : ""}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,213 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { MapPin, Search, Heart } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMembers, useMember } from "@/lib/hooks/useMembers";
import { useFollows } from "@/lib/hooks/useFollows";
import { useAuthStore } from "@/stores/authStore";
import { FollowButton } from "@/components/shared/FollowButton";
import { membersAPI } from "@/lib/api";
import { cn, partyBadgeColor } from "@/lib/utils";
import type { Member } from "@/lib/types";
function MemberCard({ member }: { member: Member }) {
return (
<div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
{member.photo_url ? (
<img src={member.photo_url} alt={member.name} className="w-10 h-10 rounded-full object-cover shrink-0 border border-border" />
) : (
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-sm font-semibold text-muted-foreground shrink-0">
{member.name[0]}
</div>
)}
<div className="flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors">
{member.name}
</Link>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{member.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div>
{(member.phone || member.official_url) && (
<div className="flex items-center gap-2 mt-1">
{member.phone && (
<a href={`tel:${member.phone.replace(/\D/g, "")}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
{member.phone}
</a>
)}
{member.official_url && (
<a href={member.official_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">
Contact
</a>
)}
</div>
)}
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
</div>
);
}
function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
const { data: member } = useMember(bioguideId);
if (!member) return null;
return <MemberCard member={member} />;
}
export default function MembersPage() {
const [q, setQ] = useState("");
const [chamber, setChamber] = useState("");
const [party, setParty] = useState("");
const [page, setPage] = useState(1);
const [zipInput, setZipInput] = useState("");
const [submittedZip, setSubmittedZip] = useState("");
const { data, isLoading } = useMembers({
...(q && { q }), ...(chamber && { chamber }), ...(party && { party }),
page, per_page: 50,
});
const token = useAuthStore((s) => s.token);
const { data: follows } = useFollows();
const followedMemberIds = follows?.filter((f) => f.follow_type === "member").map((f) => f.follow_value) ?? [];
const isValidZip = /^\d{5}$/.test(submittedZip);
const { data: myReps, isFetching: repsFetching, error: repsError } = useQuery({
queryKey: ["members-by-zip", submittedZip],
queryFn: () => membersAPI.byZip(submittedZip),
enabled: isValidZip,
staleTime: 24 * 60 * 60 * 1000,
retry: false,
});
function handleZipSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmittedZip(zipInput.trim());
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Members</h1>
<p className="text-muted-foreground text-sm mt-1">Browse current Congress members</p>
</div>
{/* Zip lookup */}
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<p className="text-sm font-medium flex items-center gap-2">
<MapPin className="w-4 h-4 text-primary" />
Find your representatives
</p>
<form onSubmit={handleZipSubmit} className="flex gap-2">
<input
type="text"
value={zipInput}
onChange={(e) => setZipInput(e.target.value)}
placeholder="Enter ZIP code"
maxLength={5}
className="px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary w-40"
/>
<button
type="submit"
disabled={!/^\d{5}$/.test(zipInput.trim())}
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
Find
</button>
</form>
{repsFetching && (
<p className="text-sm text-muted-foreground">Looking up representatives</p>
)}
{repsError && (
<p className="text-sm text-destructive">Could not look up representatives. Check your ZIP and try again.</p>
)}
{isValidZip && !repsFetching && myReps && myReps.length === 0 && (
<p className="text-sm text-muted-foreground">No representatives found for {submittedZip}.</p>
)}
{myReps && myReps.length > 0 && (
<div className="space-y-2 pt-1">
<p className="text-xs text-muted-foreground">Representatives for ZIP {submittedZip}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{myReps.map((rep) => (
<MemberCard key={rep.bioguide_id} member={rep} />
))}
</div>
</div>
)}
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name..."
value={q}
onChange={(e) => { setQ(e.target.value); setPage(1); }}
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
/>
</div>
<select value={chamber} onChange={(e) => { setChamber(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-card border border-border rounded-md">
<option value="">All Chambers</option>
<option value="House of Representatives">House</option>
<option value="Senate">Senate</option>
</select>
<select value={party} onChange={(e) => { setParty(e.target.value); setPage(1); }}
className="px-3 py-2 text-sm bg-card border border-border rounded-md">
<option value="">All Parties</option>
<option value="Democratic">Democratic</option>
<option value="Republican">Republican</option>
<option value="Independent">Independent</option>
</select>
</div>
{token && followedMemberIds.length > 0 && (
<div className="space-y-3">
<h2 className="font-semibold text-sm flex items-center gap-2">
<Heart className="w-4 h-4 text-red-500 fill-red-500" />
Following ({followedMemberIds.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{followedMemberIds.map((id) => (
<FollowedMemberRow key={id} bioguideId={id} />
))}
</div>
<div className="border-t border-border pt-2" />
</div>
)}
{isLoading ? (
<div className="text-center py-20 text-muted-foreground">Loading members...</div>
) : (
<>
<div className="text-sm text-muted-foreground">{data?.total ?? 0} members</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{data?.items?.map((member) => (
<MemberCard key={member.bioguide_id} member={member} />
))}
</div>
{data && data.pages > 1 && (
<div className="flex justify-center gap-2">
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}
className="px-4 py-2 text-sm bg-card border border-border rounded-md disabled:opacity-40 hover:bg-accent">Previous</button>
<button onClick={() => setPage((p) => Math.min(data.pages, p + 1))} disabled={page === data.pages}
className="px-4 py-2 text-sm bg-card border border-border rounded-md disabled:opacity-40 hover:bg-accent">Next</button>
</div>
)}
</>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

97
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,97 @@
"use client";
import { TrendingUp, BookOpen, Flame } from "lucide-react";
import Link from "next/link";
import { useDashboard } from "@/lib/hooks/useDashboard";
import { BillCard } from "@/components/shared/BillCard";
import { WelcomeBanner } from "@/components/shared/WelcomeBanner";
import { useAuthStore } from "@/stores/authStore";
export default function DashboardPage() {
const { data, isLoading } = useDashboard();
const token = useAuthStore((s) => s.token);
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground text-sm mt-1">
Your personalized Congressional activity feed
</p>
</div>
<WelcomeBanner />
{isLoading ? (
<div className="text-center py-20 text-muted-foreground">Loading dashboard...</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-8">
<div className="md:col-span-2 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
{token ? <BookOpen className="w-4 h-4" /> : <Flame className="w-4 h-4" />}
{token ? "Your Feed" : "Most Popular"}
{token && data?.follows && (
<span className="text-xs text-muted-foreground font-normal">
({data.follows.bills} bills · {data.follows.members} members · {data.follows.topics} topics)
</span>
)}
</h2>
{!token ? (
<div className="space-y-3">
<div className="rounded-lg border border-dashed px-4 py-3 flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
Sign in to personalise this feed with bills and members you follow.
</p>
<div className="flex gap-2 shrink-0">
<Link href="/register" className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
Register
</Link>
<Link href="/login" className="px-3 py-1.5 text-xs font-medium rounded-md border border-border text-foreground hover:bg-accent transition-colors">
Sign in
</Link>
</div>
</div>
{data?.trending?.length ? (
<div className="space-y-3">
{data.trending.map((bill) => (
<BillCard key={bill.bill_id} bill={bill} />
))}
</div>
) : null}
</div>
) : !data?.feed?.length ? (
<div className="bg-card border border-border rounded-lg p-8 text-center text-muted-foreground">
<p className="text-sm">Your feed is empty.</p>
<p className="text-xs mt-1">Follow bills, members, or topics to see activity here.</p>
</div>
) : (
<div className="space-y-3">
{data.feed.map((bill) => (
<BillCard key={bill.bill_id} bill={bill} />
))}
</div>
)}
</div>
<div className="space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Trending
</h2>
{!data?.trending?.length ? (
<div className="bg-card border border-border rounded-lg p-6 text-center text-muted-foreground text-xs">
No trend data yet.
</div>
) : (
<div className="space-y-2">
{data.trending.map((bill) => (
<BillCard key={bill.bill_id} bill={bill} compact />
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{children}
</ThemeProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { authAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
export default function RegisterPage() {
const router = useRouter();
const setAuth = useAuthStore((s) => s.setAuth);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
setLoading(true);
try {
const { access_token, user } = await authAPI.register(email.trim(), password);
setAuth(access_token, { id: user.id, email: user.email, is_admin: user.is_admin });
router.replace("/");
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
"Registration failed. Please try again.";
setError(msg);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-full max-w-sm space-y-6 p-8 border rounded-lg bg-card shadow-sm">
<div>
<h1 className="text-2xl font-bold">PocketVeto</h1>
<p className="text-muted-foreground text-sm mt-1">Create your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">Email</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
Password <span className="text-muted-foreground font-normal">(min 8 chars)</span>
</label>
<input
id="password"
type="password"
required
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Creating account..." : "Create account"}
</button>
</form>
<p className="text-sm text-center text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,976 @@
"use client";
import React, { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Settings,
Cpu,
RefreshCw,
CheckCircle,
XCircle,
Play,
Users,
Trash2,
ShieldCheck,
ShieldOff,
BarChart3,
Bell,
Shield,
Zap,
ChevronDown,
ChevronRight,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { settingsAPI, adminAPI, notificationsAPI, type AdminUser, type LLMModel, type ApiHealthResult, alignmentAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
function relativeTime(isoStr: string): string {
const diff = Date.now() - new Date(isoStr.endsWith("Z") ? isoStr : isoStr + "Z").getTime();
const hours = Math.floor(diff / 3_600_000);
const mins = Math.floor((diff % 3_600_000) / 60_000);
return hours > 0 ? `${hours}h ${mins}m ago` : `${mins}m ago`;
}
const LLM_PROVIDERS = [
{
value: "openai",
label: "OpenAI",
hint: "Requires OPENAI_API_KEY in .env",
rateNote: "Free: 3 RPM · Paid tier 1: 500 RPM",
modelNote: "Recommended: gpt-4o-mini — excellent JSON quality at ~10× lower cost than gpt-4o",
},
{
value: "anthropic",
label: "Anthropic (Claude)",
hint: "Requires ANTHROPIC_API_KEY in .env",
rateNote: "Tier 1: 50 RPM · Tier 2: 1,000 RPM",
modelNote: "Recommended: claude-sonnet-4-6 — matches Opus quality at ~5× lower cost",
},
{
value: "gemini",
label: "Google Gemini",
hint: "Requires GEMINI_API_KEY in .env",
rateNote: "Free: 15 RPM · Paid: 2,000 RPM",
modelNote: "Recommended: gemini-2.0-flash — best value, generous free tier",
},
{
value: "ollama",
label: "Ollama (Local)",
hint: "Requires Ollama running on host",
rateNote: "No API rate limits",
modelNote: "Recommended: llama3.1 or mistral for reliable structured JSON output",
},
];
export default function SettingsPage() {
const qc = useQueryClient();
const currentUser = useAuthStore((s) => s.user);
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get(),
});
const { data: stats } = useQuery({
queryKey: ["admin-stats"],
queryFn: () => adminAPI.getStats(),
enabled: !!currentUser?.is_admin,
refetchInterval: 30_000,
});
const [healthTesting, setHealthTesting] = useState(false);
const [healthData, setHealthData] = useState<Record<string, ApiHealthResult> | null>(null);
const testApiHealth = async () => {
setHealthTesting(true);
try {
const result = await adminAPI.getApiHealth();
setHealthData(result as unknown as Record<string, ApiHealthResult>);
} finally {
setHealthTesting(false);
}
};
const { data: users, isLoading: usersLoading } = useQuery({
queryKey: ["admin-users"],
queryFn: () => adminAPI.listUsers(),
enabled: !!currentUser?.is_admin,
});
const updateSetting = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) => settingsAPI.update(key, value),
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings"] }),
});
const deleteUser = useMutation({
mutationFn: (id: number) => adminAPI.deleteUser(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
});
const toggleAdmin = useMutation({
mutationFn: (id: number) => adminAPI.toggleAdmin(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
});
// Live model list from provider API
const { data: modelsData, isFetching: modelsFetching, refetch: refetchModels } = useQuery({
queryKey: ["llm-models", settings?.llm_provider],
queryFn: () => settingsAPI.listModels(settings!.llm_provider),
enabled: !!currentUser?.is_admin && !!settings?.llm_provider,
staleTime: 5 * 60 * 1000,
retry: false,
});
const liveModels: LLMModel[] = modelsData?.models ?? [];
const modelsError: string | undefined = modelsData?.error;
// Model picker state
const [showCustomModel, setShowCustomModel] = useState(false);
const [customModel, setCustomModel] = useState("");
useEffect(() => {
if (!settings || modelsFetching) return;
const inList = liveModels.some((m) => m.id === settings.llm_model);
if (!inList && settings.llm_model) {
setShowCustomModel(true);
setCustomModel(settings.llm_model);
} else {
setShowCustomModel(false);
setCustomModel("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings?.llm_provider, settings?.llm_model, modelsFetching]);
const [testResult, setTestResult] = useState<{
status: string;
detail?: string;
reply?: string;
provider?: string;
model?: string;
} | null>(null);
const [testing, setTesting] = useState(false);
const [modeTestResults, setModeTestResults] = useState<Record<string, { status: string; detail: string }>>({});
const [modeTestRunning, setModeTestRunning] = useState<Record<string, boolean>>({});
const runModeTest = async (key: string, mode: string, event_type: string) => {
setModeTestRunning((p) => ({ ...p, [key]: true }));
try {
const result = await notificationsAPI.testFollowMode(mode, event_type);
setModeTestResults((p) => ({ ...p, [key]: result }));
} catch (e: unknown) {
setModeTestResults((p) => ({
...p,
[key]: { status: "error", detail: e instanceof Error ? e.message : String(e) },
}));
} finally {
setModeTestRunning((p) => ({ ...p, [key]: false }));
}
};
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
const [taskStatuses, setTaskStatuses] = useState<Record<string, "running" | "done" | "error">>({});
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [showMaintenance, setShowMaintenance] = useState(false);
const { data: newsApiQuota, refetch: refetchQuota } = useQuery({
queryKey: ["newsapi-quota"],
queryFn: () => adminAPI.getNewsApiQuota(),
enabled: !!currentUser?.is_admin && !!settings?.newsapi_enabled,
staleTime: 60_000,
});
const { data: batchStatus } = useQuery({
queryKey: ["llm-batch-status"],
queryFn: () => adminAPI.getLlmBatchStatus(),
enabled: !!currentUser?.is_admin,
refetchInterval: (query) => query.state.data?.status === "processing" ? 30_000 : false,
});
const [clearingCache, setClearingCache] = useState(false);
const [cacheClearResult, setCacheClearResult] = useState<string | null>(null);
const clearGnewsCache = async () => {
setClearingCache(true);
setCacheClearResult(null);
try {
const result = await adminAPI.clearGnewsCache();
setCacheClearResult(`Cleared ${result.cleared} cached entries`);
} catch (e: unknown) {
setCacheClearResult(e instanceof Error ? e.message : "Failed");
} finally {
setClearingCache(false);
}
};
const testLLM = async () => {
setTesting(true);
setTestResult(null);
try {
const result = await settingsAPI.testLLM();
setTestResult(result);
} catch (e: unknown) {
setTestResult({ status: "error", detail: e instanceof Error ? e.message : String(e) });
} finally {
setTesting(false);
}
};
const pollTaskStatus = async (name: string, taskId: string) => {
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
try {
const data = await adminAPI.getTaskStatus(taskId);
if (["SUCCESS", "FAILURE", "REVOKED"].includes(data.status)) {
setTaskStatuses((prev) => ({ ...prev, [name]: data.status === "SUCCESS" ? "done" : "error" }));
qc.invalidateQueries({ queryKey: ["admin-stats"] });
return;
}
} catch { /* ignore polling errors */ }
}
setTaskStatuses((prev) => ({ ...prev, [name]: "error" }));
};
const trigger = async (name: string, fn: () => Promise<{ task_id: string }>) => {
const result = await fn();
setTaskIds((prev) => ({ ...prev, [name]: result.task_id }));
setTaskStatuses((prev) => ({ ...prev, [name]: "running" }));
pollTaskStatus(name, result.task_id);
};
if (settingsLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
if (!currentUser?.is_admin) {
return <div className="text-center py-20 text-muted-foreground">Admin access required.</div>;
}
const pct = stats && stats.total_bills > 0
? Math.round((stats.briefs_generated / stats.total_bills) * 100)
: 0;
return (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Settings className="w-5 h-5" /> Admin
</h1>
<p className="text-muted-foreground text-sm mt-1">Manage users, LLM provider, and system settings</p>
</div>
{/* Notifications link */}
<Link
href="/notifications"
className="flex items-center justify-between bg-card border border-border rounded-lg p-4 hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-3">
<Bell className="w-4 h-4 text-muted-foreground group-hover:text-foreground" />
<div>
<div className="text-sm font-medium">Notification Settings</div>
<div className="text-xs text-muted-foreground">Configure ntfy push alerts and RSS feed per user</div>
</div>
</div>
<span className="text-xs text-muted-foreground group-hover:text-foreground"></span>
</Link>
{/* Follow Mode Notification Testing */}
<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" /> Follow Mode Notifications
</h2>
<p className="text-xs text-muted-foreground mt-1">
Requires at least one bill followed and ntfy configured. Tests use your first followed bill.
</p>
</div>
<div className="divide-y divide-border">
{([
{
key: "veto-suppress",
mode: "pocket_veto",
event_type: "new_document",
icon: Shield,
label: "Pocket Veto — suppress brief",
description: "Sends a new_document event. Dispatcher should silently drop it — no ntfy notification.",
expectColor: "text-amber-600 dark:text-amber-400",
},
{
key: "veto-deliver",
mode: "pocket_veto",
event_type: "bill_updated",
icon: Shield,
label: "Pocket Veto — deliver milestone",
description: "Sends a bill_updated (milestone) event. Dispatcher should allow it and send ntfy.",
expectColor: "text-amber-600 dark:text-amber-400",
},
{
key: "boost-deliver",
mode: "pocket_boost",
event_type: "bill_updated",
icon: Zap,
label: "Pocket Boost — deliver with actions",
description: "Sends a bill_updated event. ntfy notification should include 'View Bill' and 'Find Your Rep' action buttons.",
expectColor: "text-green-600 dark:text-green-400",
},
] as Array<{
key: string;
mode: string;
event_type: string;
icon: React.ElementType;
label: string;
description: string;
expectColor: string;
}>).map(({ key, mode, event_type, icon: Icon, label, description }) => {
const result = modeTestResults[key];
const running = modeTestRunning[key];
return (
<div key={key} className="flex items-start gap-3 py-3.5">
<Icon className="w-4 h-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0 space-y-1">
<div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted-foreground">{description}</p>
{result && (
<div className="flex items-start gap-1.5 text-xs mt-1">
{result.status === "ok"
? <CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-px" />
: <XCircle className="w-3.5 h-3.5 text-red-500 shrink-0 mt-px" />}
<span className={result.status === "ok" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{result.detail}
</span>
</div>
)}
</div>
<button
onClick={() => runModeTest(key, mode, event_type)}
disabled={running}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50"
>
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
</button>
</div>
);
})}
</div>
</section>
{/* Analysis Status */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<BarChart3 className="w-4 h-4" /> Bill Pipeline
<span className="text-xs text-muted-foreground font-normal ml-auto">refreshes every 30s</span>
</h2>
{stats ? (
<>
{/* Progress bar */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{stats.briefs_generated.toLocaleString()} analyzed ({stats.full_briefs} full · {stats.amendment_briefs} amendments)</span>
<span>{pct}% of {stats.total_bills.toLocaleString()} bills</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* Pipeline breakdown table */}
<div className="divide-y divide-border text-sm">
{[
{ label: "Total bills tracked", value: stats.total_bills, color: "text-foreground", icon: "📋" },
{ label: "Text published on Congress.gov", value: stats.docs_fetched, color: "text-blue-600 dark:text-blue-400", icon: "📄" },
{ label: "No text published yet", value: stats.no_text_bills, color: "text-muted-foreground", icon: "⏳", note: "Normal — bill text appears after committee markup" },
{ label: "AI briefs generated", value: stats.briefs_generated, color: "text-green-600 dark:text-green-400", icon: "✅" },
{ label: "Pending LLM analysis", value: stats.pending_llm, color: stats.pending_llm > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", icon: "🔄", action: stats.pending_llm > 0 ? "Resume Analysis" : undefined },
{ label: "Briefs missing citations", value: stats.uncited_briefs, color: stats.uncited_briefs > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", icon: "⚠️", action: stats.uncited_briefs > 0 ? "Backfill Citations" : undefined },
{ label: "Briefs with unlabeled points", value: stats.unlabeled_briefs, color: stats.unlabeled_briefs > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", icon: "🏷️", action: stats.unlabeled_briefs > 0 ? "Backfill Labels" : undefined },
].map(({ label, value, color, icon, note, action }) => (
<div key={label} className="flex items-center justify-between py-2.5 gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base leading-none shrink-0">{icon}</span>
<div>
<span className="text-sm">{label}</span>
{note && <p className="text-xs text-muted-foreground mt-0.5">{note}</p>}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className={`font-semibold tabular-nums ${color}`}>{value.toLocaleString()}</span>
{action && (
<span className="text-xs text-muted-foreground"> run {action}</span>
)}
</div>
</div>
))}
</div>
</>
) : (
<p className="text-sm text-muted-foreground">Loading stats...</p>
)}
</section>
{/* User Management */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<Users className="w-4 h-4" /> Users
</h2>
{usersLoading ? (
<p className="text-sm text-muted-foreground">Loading users...</p>
) : (
<div className="divide-y divide-border">
{(users ?? []).map((u: AdminUser) => (
<div key={u.id} className="flex items-center justify-between py-3 gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{u.email}</span>
{u.is_admin && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded font-medium">
admin
</span>
)}
{u.id === currentUser.id && (
<span className="text-xs text-muted-foreground">(you)</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{u.follow_count} follow{u.follow_count !== 1 ? "s" : ""} ·{" "}
joined {new Date(u.created_at).toLocaleDateString()}
</div>
</div>
{u.id !== currentUser.id && (
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => toggleAdmin.mutate(u.id)}
disabled={toggleAdmin.isPending}
title={u.is_admin ? "Remove admin" : "Make admin"}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{u.is_admin ? <ShieldOff className="w-4 h-4" /> : <ShieldCheck className="w-4 h-4" />}
</button>
{confirmDelete === u.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => { deleteUser.mutate(u.id); setConfirmDelete(null); }}
className="text-xs px-2 py-1 bg-destructive text-destructive-foreground rounded hover:bg-destructive/90"
>
Confirm
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 bg-muted rounded hover:bg-accent"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(u.id)}
title="Delete user"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</section>
{/* LLM Provider */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<Cpu className="w-4 h-4" /> LLM Provider
</h2>
<div className="space-y-2">
{LLM_PROVIDERS.map(({ value, label, hint, rateNote, modelNote }) => {
const hasKey = settings?.api_keys_configured?.[value] ?? true;
return (
<label key={value} className={`flex items-start gap-3 ${hasKey ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}>
<input
type="radio"
name="provider"
value={value}
checked={settings?.llm_provider === value}
disabled={!hasKey}
onChange={() => {
updateSetting.mutate({ key: "llm_provider", value });
setShowCustomModel(false);
setCustomModel("");
}}
className="mt-0.5"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{label}</span>
{hasKey ? (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 font-medium">
{value === "ollama" ? "local" : "key set"}
</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground font-medium">
no key
</span>
)}
</div>
<div className="text-xs text-muted-foreground">{hint}</div>
<div className="text-xs text-muted-foreground mt-0.5">{rateNote} · {modelNote}</div>
</div>
</label>
);
})}
</div>
{/* Model picker — live from provider API */}
<div className="space-y-2 pt-3 border-t border-border">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Model</label>
{modelsFetching && <span className="text-xs text-muted-foreground">Loading models</span>}
{modelsError && !modelsFetching && (
<span className="text-xs text-amber-600 dark:text-amber-400">{modelsError}</span>
)}
{!modelsFetching && liveModels.length > 0 && (
<button onClick={() => refetchModels()} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Refresh
</button>
)}
</div>
{liveModels.length > 0 ? (
<select
value={showCustomModel ? "__custom__" : (settings?.llm_model ?? "")}
onChange={(e) => {
if (e.target.value === "__custom__") {
setShowCustomModel(true);
setCustomModel(settings?.llm_model ?? "");
} else {
setShowCustomModel(false);
setCustomModel("");
updateSetting.mutate({ key: "llm_model", value: e.target.value });
}
}}
className="w-full px-3 py-1.5 text-sm bg-background border border-border rounded-md"
>
{liveModels.map((m) => (
<option key={m.id} value={m.id}>{m.name !== m.id ? `${m.name} (${m.id})` : m.id}</option>
))}
<option value="__custom__">Custom model name</option>
</select>
) : (
!modelsFetching && (
<p className="text-xs text-muted-foreground">
{modelsError ? "Could not fetch models — enter a model name manually below." : "No models found."}
</p>
)
)}
{(showCustomModel || (liveModels.length === 0 && !modelsFetching)) && (
<div className="flex gap-2">
<input
type="text"
placeholder="e.g. gpt-4o or gemini-2.0-flash"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
className="flex-1 px-3 py-1.5 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button
onClick={() => {
if (customModel.trim()) updateSetting.mutate({ key: "llm_model", value: customModel.trim() });
}}
disabled={!customModel.trim() || updateSetting.isPending}
className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
Save
</button>
</div>
)}
<p className="text-xs text-muted-foreground">
Active: <strong>{settings?.llm_provider}</strong> / <strong>{settings?.llm_model}</strong>
</p>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-border">
<button
onClick={testLLM}
disabled={testing}
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"
>
<Play className="w-3.5 h-3.5" />
{testing ? "Testing..." : "Test Connection"}
</button>
{testResult && (
<div className="flex items-center gap-2 text-sm">
{testResult.status === "ok" ? (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600 dark:text-green-400">
{testResult.model} {testResult.reply}
</span>
</>
) : (
<>
<XCircle className="w-4 h-4 text-red-500" />
<span className="text-red-600 dark:text-red-400">{testResult.detail}</span>
</>
)}
</div>
)}
</div>
</section>
{/* Data Sources */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<RefreshCw className="w-4 h-4" /> Data Sources
</h2>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Congress.gov Poll Interval</div>
<div className="text-xs text-muted-foreground">How often to check for new bills</div>
</div>
<select
value={settings?.congress_poll_interval_minutes}
onChange={(e) => updateSetting.mutate({ key: "congress_poll_interval_minutes", value: e.target.value })}
className="px-3 py-1.5 text-sm bg-background border border-border rounded-md"
>
<option value="15">Every 15 min</option>
<option value="30">Every 30 min</option>
<option value="60">Every hour</option>
<option value="360">Every 6 hours</option>
</select>
</div>
<div className="flex items-center justify-between py-2 border-t border-border">
<div>
<div className="font-medium">NewsAPI.org</div>
<div className="text-xs text-muted-foreground">100 requests/day free tier</div>
</div>
<div className="flex items-center gap-3">
{newsApiQuota && (
<span className={`text-xs ${newsApiQuota.remaining < 10 ? "text-amber-500" : "text-muted-foreground"}`}>
{newsApiQuota.remaining}/{newsApiQuota.limit} remaining today
</span>
)}
<span className={`text-xs font-medium ${settings?.newsapi_enabled ? "text-green-500" : "text-muted-foreground"}`}>
{settings?.newsapi_enabled ? "Configured" : "Not configured"}
</span>
</div>
</div>
<div className="flex items-center justify-between py-2 border-t border-border">
<div>
<div className="font-medium">Google Trends</div>
<div className="text-xs text-muted-foreground">Zeitgeist scoring via pytrends</div>
</div>
<span className={`text-xs font-medium ${settings?.pytrends_enabled ? "text-green-500" : "text-muted-foreground"}`}>
{settings?.pytrends_enabled ? "Enabled" : "Disabled"}
</span>
</div>
</div>
</section>
{/* API Health */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold">External API Health</h2>
<button
onClick={testApiHealth}
disabled={healthTesting}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-muted hover:bg-accent rounded-md transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${healthTesting ? "animate-spin" : ""}`} />
{healthTesting ? "Testing…" : "Run Tests"}
</button>
</div>
{healthData ? (
<div className="divide-y divide-border">
{[
{ key: "congress_gov", label: "Congress.gov API" },
{ key: "govinfo", label: "GovInfo API" },
{ key: "newsapi", label: "NewsAPI.org" },
{ key: "google_news", label: "Google News RSS" },
{ key: "rep_lookup", label: "Rep Lookup (Nominatim + TIGERweb)" },
].map(({ key, label }) => {
const r = healthData[key];
if (!r) return null;
return (
<div key={key} className="flex items-start justify-between py-3 gap-4">
<div>
<div className="text-sm font-medium">{label}</div>
<div className={`text-xs mt-0.5 ${
r.status === "ok" ? "text-green-600 dark:text-green-400"
: r.status === "skipped" ? "text-muted-foreground"
: "text-red-600 dark:text-red-400"
}`}>
{r.detail}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{r.latency_ms !== undefined && (
<span className="text-xs text-muted-foreground">{r.latency_ms}ms</span>
)}
{r.status === "ok" && <CheckCircle className="w-4 h-4 text-green-500" />}
{r.status === "error" && <XCircle className="w-4 h-4 text-red-500" />}
{r.status === "skipped" && <span className="text-xs text-muted-foreground"></span>}
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">
Click Run Tests to check connectivity to each external data source.
</p>
)}
</section>
{/* Manual Controls */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold">Manual Controls</h2>
{(() => {
type ControlItem = {
key: string;
name: string;
description: string;
fn: () => Promise<{ task_id: string }>;
status: "ok" | "needed" | "on-demand";
count?: number;
countLabel?: string;
};
const renderRow = ({ key, name, description, fn, status, count, countLabel }: ControlItem) => (
<div key={key} className="flex items-start gap-3 py-3.5">
<div className={`w-2.5 h-2.5 rounded-full mt-1 shrink-0 ${
status === "ok" ? "bg-green-500"
: status === "needed" ? "bg-red-500"
: "bg-border"
}`} />
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{name}</span>
{taskStatuses[key] === "running" ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<RefreshCw className="w-3 h-3 animate-spin" />
running
{taskIds[key] && (
<code className="font-mono opacity-60">{taskIds[key].slice(0, 8)}</code>
)}
</span>
) : taskStatuses[key] === "done" ? (
<span className="text-xs text-green-600 dark:text-green-400"> Complete</span>
) : taskStatuses[key] === "error" ? (
<span className="text-xs text-red-600 dark:text-red-400"> Failed</span>
) : status === "ok" ? (
<span className="text-xs text-green-600 dark:text-green-400"> Up to date</span>
) : status === "needed" && count !== undefined && count > 0 ? (
<span className="text-xs text-red-600 dark:text-red-400">
{count.toLocaleString()} {countLabel}
</span>
) : null}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
</div>
<button
onClick={() => trigger(key, fn)}
disabled={taskStatuses[key] === "running"}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{taskStatuses[key] === "running" ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
</button>
</div>
);
// Clear RSS cache — inline action (returns count, not task_id)
const ClearCacheRow = (
<div className="flex items-start gap-3 py-3.5">
<div className="w-2.5 h-2.5 rounded-full mt-1 shrink-0 bg-border" />
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">Clear Google News Cache</span>
{cacheClearResult && (
<span className="text-xs text-green-600 dark:text-green-400"> {cacheClearResult}</span>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Flush the 2-hour Google News RSS cache so fresh articles are fetched on the next trend scoring or news run.
</p>
</div>
<button
onClick={clearGnewsCache}
disabled={clearingCache}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{clearingCache ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
</button>
</div>
);
const recurring: ControlItem[] = [
{
key: "poll",
name: "Trigger Poll",
description: "Check Congress.gov for newly introduced or updated bills. Runs automatically on a schedule — use this to force an immediate sync.",
fn: adminAPI.triggerPoll,
status: "on-demand",
},
{
key: "members",
name: "Sync Members",
description: "Refresh all member profiles from Congress.gov including biography, current term, leadership roles, and contact information.",
fn: adminAPI.triggerMemberSync,
status: "on-demand",
},
{
key: "trends",
name: "Calculate Trends",
description: "Score bill and member newsworthiness by counting recent news headlines and Google search interest. Updates the trend charts.",
fn: adminAPI.triggerTrendScores,
status: "on-demand",
},
{
key: "actions",
name: "Fetch Bill Actions",
description: "Download the full legislative history (votes, referrals, amendments) for recently active bills and populate the timeline view.",
fn: adminAPI.triggerFetchActions,
status: "on-demand",
},
{
key: "resume",
name: "Resume Analysis",
description: "Restart AI brief generation for bills where processing stalled or failed (e.g. after an LLM quota outage). Also re-queues document fetching for bills that have no text yet.",
fn: adminAPI.resumeAnalysis,
status: stats ? (stats.pending_llm > 0 ? "needed" : "on-demand") : "on-demand",
count: stats?.pending_llm,
countLabel: "bills pending analysis",
},
{
key: "weekly-digest",
name: "Send Weekly Digest",
description: "Immediately dispatch the weekly bill activity summary to all users who have ntfy or RSS enabled and at least one bill followed. Runs automatically every Monday at 8:30 AM UTC.",
fn: adminAPI.triggerWeeklyDigest,
status: "on-demand",
},
];
if (settings?.llm_provider === "openai" || settings?.llm_provider === "anthropic") {
recurring.push({
key: "llm-batch",
name: "Submit LLM Batch (50% off)",
description: "Send all unbriefed documents to the Batch API for overnight processing at half the token cost. Returns within seconds — results are imported automatically every 30 minutes via the background poller.",
fn: adminAPI.submitLlmBatch,
status: "on-demand",
});
}
const maintenance: ControlItem[] = [
{
key: "cosponsors",
name: "Backfill Co-sponsors",
description: "Fetch co-sponsor lists from Congress.gov for all bills. Required for bipartisan multiplier in effectiveness scoring.",
fn: adminAPI.backfillCosponsors,
status: "on-demand",
},
{
key: "categories",
name: "Classify Bill Categories",
description: "Run a lightweight LLM call on each bill to classify it as substantive, commemorative, or administrative. Used to weight effectiveness scores.",
fn: adminAPI.backfillCategories,
status: "on-demand",
},
{
key: "effectiveness",
name: "Calculate Effectiveness Scores",
description: "Score all members by legislative output, bipartisanship, bill substance, and committee leadership. Runs automatically nightly at 5 AM UTC.",
fn: adminAPI.calculateEffectiveness,
status: "on-demand",
},
{
key: "backfill-actions",
name: "Backfill All Action Histories",
description: "One-time catch-up: fetch action histories for all bills that were imported before this feature existed.",
fn: adminAPI.backfillAllActions,
status: stats ? (stats.bills_missing_actions > 0 ? "needed" : "ok") : "on-demand",
count: stats?.bills_missing_actions,
countLabel: "bills missing action history",
},
{
key: "sponsors",
name: "Backfill Sponsors",
description: "Link bill sponsors that weren't captured during the initial import. Safe to re-run — skips bills that already have a sponsor.",
fn: adminAPI.backfillSponsors,
status: stats ? (stats.bills_missing_sponsor > 0 ? "needed" : "ok") : "on-demand",
count: stats?.bills_missing_sponsor,
countLabel: "bills missing sponsor",
},
{
key: "metadata",
name: "Backfill Dates & Links",
description: "Fill in missing introduced dates, chamber assignments, and congress.gov links by re-fetching bill detail from Congress.gov.",
fn: adminAPI.backfillMetadata,
status: stats ? (stats.bills_missing_metadata > 0 ? "needed" : "ok") : "on-demand",
count: stats?.bills_missing_metadata,
countLabel: "bills missing metadata",
},
{
key: "citations",
name: "Backfill Citations",
description: "Regenerate AI briefs created before inline source citations were added. Deletes the old brief and re-runs LLM analysis using already-stored bill text.",
fn: adminAPI.backfillCitations,
status: stats ? (stats.uncited_briefs > 0 ? "needed" : "ok") : "on-demand",
count: stats?.uncited_briefs,
countLabel: "briefs need regeneration",
},
{
key: "labels",
name: "Backfill Fact/Inference Labels",
description: "Classify existing cited brief points as fact or inference. One compact LLM call per brief — no re-generation of summaries or citations.",
fn: adminAPI.backfillLabels,
status: stats ? (stats.unlabeled_briefs > 0 ? "needed" : "ok") : "on-demand",
count: stats?.unlabeled_briefs,
countLabel: "briefs with unlabeled points",
},
];
const maintenanceNeeded = maintenance.some((m) => m.status === "needed");
return (
<>
<div className="divide-y divide-border">
{recurring.map(renderRow)}
{batchStatus?.status === "processing" && (
<div className="py-2 pl-6 text-xs text-muted-foreground">
Batch in progress · {batchStatus.doc_count} documents · submitted {relativeTime(batchStatus.submitted_at!)}
</div>
)}
{ClearCacheRow}
</div>
{/* Maintenance subsection */}
<div className="border border-border rounded-md overflow-hidden">
<button
onClick={() => setShowMaintenance((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium bg-muted/50 hover:bg-muted transition-colors"
>
<span className="flex items-center gap-2">
<Wrench className="w-3.5 h-3.5 text-muted-foreground" />
Maintenance
{maintenanceNeeded && (
<span className="text-xs font-normal text-red-600 dark:text-red-400"> action needed</span>
)}
</span>
{showMaintenance
? <ChevronDown className="w-4 h-4 text-muted-foreground" />
: <ChevronRight className="w-4 h-4 text-muted-foreground" />}
</button>
{showMaintenance && (
<div className="divide-y divide-border px-4">
{maintenance.map(renderRow)}
</div>
)}
</div>
</>
);
})()}
</section>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { use } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { ExternalLink, Landmark } from "lucide-react";
import { shareAPI } from "@/lib/api";
import { AIBriefCard } from "@/components/bills/AIBriefCard";
import { billLabel } from "@/lib/utils";
export default function SharedBriefPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params);
const { data, isLoading, isError } = useQuery({
queryKey: ["share-brief", token],
queryFn: () => shareAPI.getBrief(token),
retry: false,
});
return (
<div className="min-h-screen bg-background">
{/* Minimal header */}
<header className="border-b border-border bg-card px-6 py-3 flex items-center gap-2">
<Landmark className="w-5 h-5 text-primary" />
<Link href="/" className="font-semibold text-sm hover:opacity-70 transition-opacity">
PocketVeto
</Link>
</header>
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
{isLoading && (
<div className="text-center py-20 text-muted-foreground text-sm">Loading</div>
)}
{isError && (
<div className="text-center py-20">
<p className="text-muted-foreground">Brief not found or link is invalid.</p>
</div>
)}
{data && (
<>
{/* Bill label + title */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded">
{billLabel(data.bill.bill_type, data.bill.bill_number)}
</span>
</div>
<h1 className="text-xl font-bold leading-snug">
{data.bill.short_title || data.bill.title || "Untitled Bill"}
</h1>
</div>
{/* Full brief */}
<AIBriefCard brief={data.brief} />
{/* CTAs */}
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Link
href={`/bills/${data.bill.bill_id}`}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-border hover:bg-accent transition-colors"
>
View full bill page <ExternalLink className="w-3.5 h-3.5" />
</Link>
<Link
href="/register"
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Track this bill on PocketVeto
</Link>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { use } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Landmark } from "lucide-react";
import { shareAPI } from "@/lib/api";
import type { Bill } from "@/lib/types";
import { billLabel, formatDate } from "@/lib/utils";
export default function SharedCollectionPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params);
const { data: collection, isLoading, isError } = useQuery({
queryKey: ["share-collection", token],
queryFn: () => shareAPI.getCollection(token),
retry: false,
});
return (
<div className="min-h-screen bg-background">
{/* Minimal header */}
<header className="border-b border-border bg-card px-6 py-3 flex items-center gap-2">
<Landmark className="w-5 h-5 text-primary" />
<Link href="/" className="font-semibold text-sm hover:opacity-70 transition-opacity">
PocketVeto
</Link>
</header>
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
{isLoading && (
<div className="text-center py-20 text-muted-foreground text-sm">Loading</div>
)}
{isError && (
<div className="text-center py-20">
<p className="text-muted-foreground">Collection not found or link is invalid.</p>
</div>
)}
{collection && (
<>
{/* Header */}
<div>
<h1 className="text-xl font-bold">{collection.name}</h1>
<p className="text-sm text-muted-foreground mt-1">
{collection.bill_count} {collection.bill_count === 1 ? "bill" : "bills"}
</p>
</div>
{/* Bill list */}
{collection.bills.length === 0 ? (
<p className="text-sm text-muted-foreground">No bills in this collection.</p>
) : (
<div className="space-y-2">
{collection.bills.map((bill: Bill) => (
<Link
key={bill.bill_id}
href={`/bills/${bill.bill_id}`}
className="block bg-card border border-border rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground shrink-0">
{billLabel(bill.bill_type, bill.bill_number)}
</span>
<span className="text-sm font-medium truncate">
{bill.short_title || bill.title || "Untitled"}
</span>
</div>
{bill.latest_action_date && (
<p className="text-xs text-muted-foreground mt-0.5">
Latest action: {formatDate(bill.latest_action_date)}
</p>
)}
</Link>
))}
</div>
)}
{/* CTA */}
<div className="pt-2">
<Link
href="/register"
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Follow these bills on PocketVeto
</Link>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { Tags } from "lucide-react";
import { FollowButton } from "@/components/shared/FollowButton";
import { TOPICS } from "@/lib/topics";
export default function TopicsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Topics</h1>
<p className="text-muted-foreground text-sm mt-1">
Follow topics to see related bills in your feed
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{TOPICS.map(({ tag, label, desc }) => (
<div key={tag} className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<Tags className="w-3.5 h-3.5 text-muted-foreground" />
<Link
href={`/bills?topic=${tag}`}
className="font-medium text-sm hover:text-primary transition-colors"
>
{label}
</Link>
</div>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<FollowButton type="topic" value={tag} />
</div>
))}
</div>
</div>
);
}