Files
PocketVeto/frontend/components/bills/VotePanel.tsx
Jack Levy 4c86a5b9ca 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
2026-03-15 01:35:01 -04:00

227 lines
7.6 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ListChecks, ChevronDown, ChevronUp } from "lucide-react";
import { billsAPI, followsAPI } from "@/lib/api";
import { cn, formatDate, partyBadgeColor } from "@/lib/utils";
import type { BillVote, MemberVotePosition } from "@/lib/types";
interface VotePanelProps {
billId: string;
alwaysRender?: boolean;
}
export function VotePanel({ billId, alwaysRender = false }: VotePanelProps) {
const [expanded, setExpanded] = useState(true);
const { data: votes, isLoading } = useQuery({
queryKey: ["votes", billId],
queryFn: () => billsAPI.getVotes(billId),
staleTime: 5 * 60 * 1000,
});
const { data: follows } = useQuery({
queryKey: ["follows"],
queryFn: () => followsAPI.list(),
retry: false,
throwOnError: false,
});
const followedMemberIds = new Set(
(follows || [])
.filter((f) => f.follow_type === "member")
.map((f) => f.follow_value)
);
if (isLoading || !votes || votes.length === 0) {
if (!alwaysRender) return null;
return (
<div className="bg-card border border-border rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground">
{isLoading ? "Checking for roll-call votes…" : "No roll-call votes have been recorded for this bill."}
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<button
onClick={() => setExpanded((e) => !e)}
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<ListChecks className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-sm">
Roll-Call Votes{" "}
<span className="text-muted-foreground font-normal">({votes.length})</span>
</span>
</div>
{expanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
{expanded && (
<div className="divide-y divide-border">
{votes.map((vote) => (
<VoteRow key={vote.id} vote={vote} followedMemberIds={followedMemberIds} />
))}
</div>
)}
</div>
);
}
function VoteRow({
vote,
followedMemberIds,
}: {
vote: BillVote;
followedMemberIds: Set<string>;
}) {
const [showPositions, setShowPositions] = useState(false);
const total = (vote.yeas ?? 0) + (vote.nays ?? 0) + (vote.not_voting ?? 0);
const yeaPct = total > 0 ? ((vote.yeas ?? 0) / total) * 100 : 0;
const nayPct = total > 0 ? ((vote.nays ?? 0) / total) * 100 : 0;
const resultLower = (vote.result ?? "").toLowerCase();
const passed =
resultLower.includes("pass") ||
resultLower.includes("agreed") ||
resultLower.includes("adopted") ||
resultLower.includes("enacted");
const followedPositions: MemberVotePosition[] = vote.positions.filter(
(p) => p.bioguide_id && followedMemberIds.has(p.bioguide_id)
);
return (
<div className="p-4 space-y-3">
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div className="space-y-1 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{vote.result && (
<span
className={cn(
"text-xs px-2 py-0.5 rounded font-medium shrink-0",
passed
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
)}
>
{vote.result}
</span>
)}
<span className="text-xs text-muted-foreground">
{vote.chamber} Roll #{vote.roll_number}
{vote.vote_date && ` · ${formatDate(vote.vote_date)}`}
</span>
</div>
{vote.question && (
<p className="text-sm font-medium">{vote.question}</p>
)}
</div>
{vote.source_url && (
<a
href={vote.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary transition-colors shrink-0"
>
Source
</a>
)}
</div>
{/* Yea / Nay bar */}
{total > 0 && (
<div className="space-y-1.5">
<div className="flex h-2 rounded overflow-hidden bg-muted gap-0.5">
<div
className="bg-emerald-500 transition-all"
style={{ width: `${yeaPct}%` }}
title={`Yea: ${vote.yeas}`}
/>
<div
className="bg-red-500 transition-all"
style={{ width: `${nayPct}%` }}
title={`Nay: ${vote.nays}`}
/>
</div>
<div className="flex items-center gap-4 text-xs">
<span className="text-emerald-600 dark:text-emerald-400 font-medium">
{vote.yeas ?? "—"} Yea
</span>
<span className="text-red-600 dark:text-red-400 font-medium">
{vote.nays ?? "—"} Nay
</span>
{(vote.not_voting ?? 0) > 0 && (
<span className="text-muted-foreground">{vote.not_voting} Not Voting</span>
)}
</div>
</div>
)}
{/* Followed member positions */}
{followedPositions.length > 0 && (
<div>
<button
onClick={() => setShowPositions((e) => !e)}
className="text-xs text-primary hover:underline"
>
{showPositions ? "Hide" : "Show"} {followedPositions.length} followed member
{followedPositions.length !== 1 ? "s'" : "'"} vote
{followedPositions.length !== 1 ? "s" : ""}
</button>
{showPositions && (
<div className="mt-2 space-y-1.5">
{followedPositions.map((p, i) => (
<div key={p.bioguide_id ?? i} className="flex items-center gap-2 text-xs">
<span
className={cn(
"w-2 h-2 rounded-full shrink-0",
p.position === "Yea"
? "bg-emerald-500"
: p.position === "Nay"
? "bg-red-500"
: "bg-muted-foreground"
)}
/>
<span className="text-muted-foreground shrink-0">
{vote.chamber === "Senate" ? "Sen." : "Rep."}
</span>
<span className="font-medium">{p.member_name}</span>
{p.party && (
<span className={cn("px-1 py-0.5 rounded font-medium shrink-0", partyBadgeColor(p.party))}>
{p.party}
</span>
)}
{p.state && <span className="text-muted-foreground">{p.state}</span>}
<span
className={cn(
"ml-auto font-medium shrink-0",
p.position === "Yea"
? "text-emerald-600 dark:text-emerald-400"
: p.position === "Nay"
? "text-red-600 dark:text-red-400"
: "text-muted-foreground"
)}
>
{p.position}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}