● Zion Boggan
repos/Docket/components/votes-browser.tsx
zionboggan.com ↗
234 lines · tsx
History for this file →
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
}