| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import { useSearchParams } from "next/navigation"; |
| 5 | import Link from "next/link"; |
| 6 | import { Card, SectionHeader } from "@/components/ui"; |
| 7 | import { DataTable, type Column, type Row } from "@/components/data-table"; |
| 8 | import { SourceLink } from "@/components/source-link"; |
| 9 | import { ArchiveLink } from "@/components/record-archive"; |
| 10 | import { StatusPill, classifyStatus } from "@/components/status-pill"; |
| 11 | import { |
| 12 | SearchFilterBar, |
| 13 | FilterField, |
| 14 | FilterInput, |
| 15 | FilterSelect, |
| 16 | FilterSubmit, |
| 17 | } from "@/components/search-filter-bar"; |
| 18 | import { resolveVoteSubject } from "@/lib/vote-subject"; |
| 19 | import { formatDate } from "@/lib/format"; |
| 20 | import type { Vote } from "@/lib/feed"; |
| 21 | |
| 22 | function trunc(s: string | undefined, max: number): string { |
| 23 | if (!s) return ""; |
| 24 | return s.length <= max ? s : s.slice(0, max - 1) + "…"; |
| 25 | } |
| 26 | |
| 27 | function VotesSkeleton() { |
| 28 | return ( |
| 29 | <div className="mt-8 space-y-4"> |
| 30 | <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" /> |
| 31 | <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" /> |
| 32 | <div className="space-y-1"> |
| 33 | {Array.from({ length: 16 }).map((_, i) => ( |
| 34 | <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" /> |
| 35 | ))} |
| 36 | </div> |
| 37 | </div> |
| 38 | ); |
| 39 | } |
| 40 | |
| 41 | function VotesTable({ votes }: { votes: Vote[] }) { |
| 42 | const columns: Column[] = [ |
| 43 | { key: "date", header: "Date", width: "6.5rem" }, |
| 44 | { key: "chamber", header: "Chamber", hideOnCard: true, width: "6rem" }, |
| 45 | { key: "what", header: "What was voted on", cardTitle: true }, |
| 46 | { key: "tally", header: "Tally", align: "right", width: "6rem" }, |
| 47 | { key: "record", header: "Record", align: "right", width: "13rem" }, |
| 48 | ]; |
| 49 | const rows: Row[] = votes.map((v) => { |
| 50 | const variant = classifyStatus(v.result); |
| 51 | const subject = resolveVoteSubject(v); |
| 52 | return { |
| 53 | key: v.id, |
| 54 | cells: { |
| 55 | date: <span className="tabular-nums text-ash">{formatDate(v.date)}</span>, |
| 56 | chamber: <span className="uppercase text-ash-2">{v.chamber}</span>, |
| 57 | what: ( |
| 58 | <span className="block text-paper"> |
| 59 | <Link |
| 60 | href={v.href ?? "#"} |
| 61 | className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper" |
| 62 | > |
| 63 | <span |
| 64 | className={ |
| 65 | subject.kind === "bill" |
| 66 | ? "block font-mono text-[13px] text-paper" |
| 67 | : "block text-paper" |
| 68 | } |
| 69 | > |
| 70 | {trunc(subject.primary, 90)} |
| 71 | </span> |
| 72 | {subject.secondary ? ( |
| 73 | <span className="mt-0.5 block text-[12px] text-ash-2"> |
| 74 | {trunc(subject.secondary, 72)} |
| 75 | </span> |
| 76 | ) : null} |
| 77 | </Link> |
| 78 | <span className="mt-1.5 block"> |
| 79 | <StatusPill variant={variant} dot> |
| 80 | {v.recorded ? "Recorded" : "Voice / UC"} |
| 81 | </StatusPill> |
| 82 | </span> |
| 83 | </span> |
| 84 | ), |
| 85 | tally: ( |
| 86 | <span className="tabular-nums text-paper"> |
| 87 | {v.recorded ? `${v.yea}-${v.nay}` : "-"} |
| 88 | </span> |
| 89 | ), |
| 90 | record: ( |
| 91 | <span className="inline-flex flex-col items-start gap-1.5 sm:items-end"> |
| 92 | <SourceLink url={v.sourceUrl} /> |
| 93 | {v.archiveUrl ? <ArchiveLink url={v.archiveUrl} label="Archived" /> : null} |
| 94 | </span> |
| 95 | ), |
| 96 | }, |
| 97 | }; |
| 98 | }); |
| 99 | return ( |
| 100 | <DataTable |
| 101 | caption="Recorded votes: date, chamber, what was voted on, tally, and source." |
| 102 | cardLabel="Recorded votes" |
| 103 | columns={columns} |
| 104 | rows={rows} |
| 105 | pageSize={50} |
| 106 | /> |
| 107 | ); |
| 108 | } |
| 109 | |
| 110 | export function VotesBrowser() { |
| 111 | const sp = useSearchParams(); |
| 112 | const chamber = sp.get("chamber") ?? ""; |
| 113 | const q = sp.get("q") ?? ""; |
| 114 | const result = sp.get("result") ?? ""; |
| 115 | const sort = sp.get("sort") ?? ""; |
| 116 | |
| 117 | const [votes, setVotes] = useState<Vote[]>([]); |
| 118 | const [total, setTotal] = useState(0); |
| 119 | const [loading, setLoading] = useState(true); |
| 120 | |
| 121 | useEffect(() => { |
| 122 | setLoading(true); |
| 123 | const params = new URLSearchParams(); |
| 124 | if (chamber) params.set("chamber", chamber); |
| 125 | if (q) params.set("q", q); |
| 126 | if (result) params.set("result", result); |
| 127 | if (sort) params.set("sort", sort); |
| 128 | params.set("limit", "100"); |
| 129 | |
| 130 | fetch(`/api/votes?${params.toString()}`) |
| 131 | .then((r) => r.json()) |
| 132 | .then((data) => { |
| 133 | setVotes(data.items ?? []); |
| 134 | setTotal(data.total ?? 0); |
| 135 | }) |
| 136 | .catch(() => { |
| 137 | setVotes([]); |
| 138 | setTotal(0); |
| 139 | }) |
| 140 | .finally(() => setLoading(false)); |
| 141 | }, [chamber, q, result, sort]); |
| 142 | |
| 143 | const isFiltered = !!(chamber || q || result || sort); |
| 144 | const clearHref = "/votes"; |
| 145 | |
| 146 | if (loading) { |
| 147 | return <VotesSkeleton />; |
| 148 | } |
| 149 | |
| 150 | return ( |
| 151 | <div> |
| 152 | {} |
| 153 | <div className="mt-8"> |
| 154 | <form method="get" action="/votes" className="contents"> |
| 155 | <SearchFilterBar |
| 156 | count={votes.length < total ? votes.length : total} |
| 157 | total={total} |
| 158 | countLabel="vote" |
| 159 | clearHref={isFiltered ? clearHref : undefined} |
| 160 | > |
| 161 | <FilterField label="Search votes" span="2"> |
| 162 | <FilterInput |
| 163 | name="q" |
| 164 | defaultValue={q} |
| 165 | placeholder="Keyword, bill number, question…" |
| 166 | aria-label="Search votes by keyword or question" |
| 167 | /> |
| 168 | </FilterField> |
| 169 | <FilterField label="Chamber"> |
| 170 | <FilterSelect |
| 171 | name="chamber" |
| 172 | defaultValue={chamber} |
| 173 | aria-label="Filter by chamber" |
| 174 | options={[ |
| 175 | { value: "", label: "Both chambers" }, |
| 176 | { value: "senate", label: "Senate" }, |
| 177 | { value: "house", label: "House" }, |
| 178 | ]} |
| 179 | /> |
| 180 | </FilterField> |
| 181 | <FilterField label="Result"> |
| 182 | <FilterSelect |
| 183 | name="result" |
| 184 | defaultValue={result} |
| 185 | aria-label="Filter by vote result" |
| 186 | options={[ |
| 187 | { value: "", label: "All results" }, |
| 188 | { value: "passed", label: "Passed" }, |
| 189 | { value: "failed", label: "Failed" }, |
| 190 | ]} |
| 191 | /> |
| 192 | </FilterField> |
| 193 | <FilterField label="Sort by"> |
| 194 | <FilterSelect |
| 195 | name="sort" |
| 196 | defaultValue={sort} |
| 197 | aria-label="Sort votes" |
| 198 | options={[ |
| 199 | { value: "", label: "Newest first" }, |
| 200 | { value: "chamber", label: "By chamber" }, |
| 201 | { value: "result", label: "By result text" }, |
| 202 | ]} |
| 203 | /> |
| 204 | </FilterField> |
| 205 | <FilterSubmit /> |
| 206 | </SearchFilterBar> |
| 207 | </form> |
| 208 | </div> |
| 209 | |
| 210 | <div className="mt-8"> |
| 211 | <SectionHeader |
| 212 | eyebrow={`${total.toLocaleString()} vote${total === 1 ? "" : "s"}${isFiltered ? " match" : ""} · showing ${votes.length}`} |
| 213 | title="Recorded votes" |
| 214 | description="Newest action first. A vote with no Yea/Nay tally passed by voice or unanimous consent - no individual record exists for those." |
| 215 | /> |
| 216 | {votes.length === 0 ? ( |
| 217 | <Card className="p-8 text-center"> |
| 218 | <p className="display text-2xl text-paper">No votes match.</p> |
| 219 | <p className="prose-lead mx-auto mt-3 max-w-md text-ash"> |
| 220 | Adjust the filter or search above. The roll-call register covers |
| 221 | recorded House and Senate votes. |
| 222 | </p> |
| 223 | </Card> |
| 224 | ) : ( |
| 225 | <VotesTable votes={votes} /> |
| 226 | )} |
| 227 | <p className="label mt-4 text-ash-2"> |
| 228 | Source: clerk.house.gov and senate.gov roll-call XML. Figures only - |
| 229 | no characterization. |
| 230 | </p> |
| 231 | </div> |
| 232 | </div> |
| 233 | ); |
| 234 | } |