feat: roll-call votes + granular alert filter fix (v0.9.5)
Roll-call votes:
- Migration 0017: bill_votes + member_vote_positions tables
- Fetch vote XML directly from House Clerk / Senate LIS URLs
embedded in bill actions recordedVotes objects
- GET /api/bills/{id}/votes triggers background fetch on first view
- VotePanel on bill detail: yea/nay bar, result badge, followed
member positions with Sen./Rep. title, party badge, and state
Alert filter fix:
- _should_dispatch returns True when alert_filters is None so users
who haven't saved filters still receive all notifications
Authored-By: Jack Levy
This commit is contained in:
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -170,6 +171,7 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
|
||||
latestActionDate={bill.latest_action_date}
|
||||
latestActionText={bill.latest_action_text}
|
||||
/>
|
||||
<VotePanel billId={bill.bill_id} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<TrendChart data={trendData} />
|
||||
|
||||
216
frontend/components/bills/VotePanel.tsx
Normal file
216
frontend/components/bills/VotePanel.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
export function VotePanel({ billId }: 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) return null;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Bill,
|
||||
BillAction,
|
||||
BillDetail,
|
||||
BillVote,
|
||||
BriefSchema,
|
||||
Collection,
|
||||
CollectionDetail,
|
||||
@@ -78,6 +79,8 @@ export const billsAPI = {
|
||||
apiClient.get<NewsArticle[]>(`/api/bills/${id}/news`).then((r) => r.data),
|
||||
getTrend: (id: string, days?: number) =>
|
||||
apiClient.get<TrendScore[]>(`/api/bills/${id}/trend`, { params: { days } }).then((r) => r.data),
|
||||
getVotes: (id: string) =>
|
||||
apiClient.get<BillVote[]>(`/api/bills/${id}/votes`).then((r) => r.data),
|
||||
generateDraft: (id: string, body: {
|
||||
stance: string;
|
||||
recipient: string;
|
||||
|
||||
@@ -199,6 +199,31 @@ export interface CollectionDetail extends Collection {
|
||||
bills: Bill[];
|
||||
}
|
||||
|
||||
export interface MemberVotePosition {
|
||||
bioguide_id?: string;
|
||||
member_name?: string;
|
||||
party?: string;
|
||||
state?: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
export interface BillVote {
|
||||
id: number;
|
||||
congress: number;
|
||||
chamber: string;
|
||||
session: number;
|
||||
roll_number: number;
|
||||
question?: string;
|
||||
description?: string;
|
||||
vote_date?: string;
|
||||
yeas?: number;
|
||||
nays?: number;
|
||||
not_voting?: number;
|
||||
result?: string;
|
||||
source_url?: string;
|
||||
positions: MemberVotePosition[];
|
||||
}
|
||||
|
||||
export interface NotificationEvent {
|
||||
id: number;
|
||||
bill_id: string;
|
||||
|
||||
Reference in New Issue
Block a user