● Zion Boggan
repos/Docket/components/member-vote-year.tsx
zionboggan.com ↗
151 lines · tsx
History for this file →
1
"use client";
2
 
3
 
4
 
5
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
6
import Link from "next/link";
7
import { Card } from "@/components/ui";
8
import { DataTable, type Column, type Row } from "@/components/data-table";
9
import { PositionTag } from "@/components/position-tag";
10
import { formatDate } from "@/lib/format";
11
import type { MemberVote } from "@/lib/feed";
12
 
13
interface Props {
14
  bioguide: string;
15
  year: number | null;
16
  count: number;
17
  defaultOpen?: boolean;
18
}
19
 
20
export function MemberVoteYear({
21
  bioguide,
22
  year,
23
  count,
24
  defaultOpen = false,
25
}: Props) {
26
  const [open, setOpen] = useState(defaultOpen);
27
  const [votes, setVotes] = useState<MemberVote[] | null>(null);
28
  const [loading, setLoading] = useState(false);
29
  const [error, setError] = useState(false);
30
  const fetchedRef = useRef(false);
31
 
32
  const doFetch = useCallback(() => {
33
    if (fetchedRef.current) return;
34
    fetchedRef.current = true;
35
    setLoading(true);
36
    const yearParam = year == null ? "null" : year.toString();
37
    fetch(`/api/members/${bioguide}/votes?year=${yearParam}`)
38
      .then((r) => {
39
        if (!r.ok) throw new Error("fetch failed");
40
        return r.json() as Promise<{ votes: MemberVote[] }>;
41
      })
42
      .then((data) => {
43
        setVotes(data.votes);
44
        setLoading(false);
45
      })
46
      .catch(() => {
47
        fetchedRef.current = false;
48
        setError(true);
49
        setLoading(false);
50
      });
51
  }, [bioguide, year]);
52
 
53
  useEffect(() => { if (defaultOpen) doFetch(); }, []);
54
 
55
  const handleToggle = useCallback(
56
    (e: React.SyntheticEvent<HTMLDetailsElement>) => {
57
      const nowOpen = e.currentTarget.open;
58
      setOpen(nowOpen);
59
      if (nowOpen) doFetch();
60
    },
61
    [doFetch]
62
  );
63
 
64
  return (
65
    <Card className="overflow-hidden">
66
      {
67
}
68
      <details open={open} className="overflow-hidden" onToggle={handleToggle}>
69
        <summary className="focus-ring flex cursor-pointer items-center justify-between gap-3 px-4 py-3.5">
70
          <span className="font-mono text-[13px] text-paper">
71
            {year ?? "Undated"}
72
          </span>
73
          <span className="label text-ash-2">
74
            {count} vote{count === 1 ? "" : "s"}
75
          </span>
76
        </summary>
77
 
78
        <div className="overflow-hidden border-t border-line">
79
          {loading && (
80
            <p className="px-4 py-3 font-mono text-[13px] text-ash-2">
81
              Loading…
82
            </p>
83
          )}
84
          {error && (
85
            <p className="px-4 py-3 font-mono text-[13px] text-ash-2">
86
              Failed to load - open again to retry.
87
            </p>
88
          )}
89
          {votes && <VoteTable votes={votes} />}
90
        </div>
91
      </details>
92
    </Card>
93
  );
94
}
95
 
96
const PAGE = 50;
97
 
98
 
99
function VoteTable({ votes }: { votes: MemberVote[] }) {
100
  const [shown, setShown] = useState(PAGE);
101
  const columns: Column[] = [
102
    { key: "date", header: "Date", width: "6.5rem" },
103
    { key: "result", header: "Question", cardTitle: true },
104
    { key: "voted", header: "Voted", width: "7rem" },
105
  ];
106
  const visible = useMemo(() => votes.slice(0, shown), [votes, shown]);
107
  const rows: Row[] = visible.map((v) => ({
108
    key: v.voteId,
109
    cells: {
110
      date: (
111
        <span className="tabular-nums text-ash">{formatDate(v.date)}</span>
112
      ),
113
      result: (
114
        <span className="text-paper">
115
          <Link
116
            href={v.voteHref}
117
            className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
118
          >
119
            {v.question || v.result}
120
          </Link>
121
          <span className="block text-ash-2">{v.result}</span>
122
        </span>
123
      ),
124
      voted: <PositionTag position={v.position} />,
125
    },
126
  }));
127
  return (
128
    <>
129
      <DataTable
130
        caption="This member's recorded votes: date, question, result, position."
131
        cardLabel="Recorded votes"
132
        columns={columns}
133
        rows={rows}
134
      />
135
      {votes.length > shown ? (
136
        <div className="flex items-center justify-between gap-3 px-4 py-3">
137
          <span className="label text-ash-2">
138
            Showing {shown.toLocaleString()} of {votes.length.toLocaleString()}
139
          </span>
140
          <button
141
            type="button"
142
            onClick={() => setShown((n) => n + PAGE)}
143
            className="focus-ring label inline-flex min-h-[40px] items-center rounded-[3px] border border-line px-3 text-ash underline underline-offset-4 hover:text-paper"
144
          >
145
            Show {Math.min(PAGE, votes.length - shown)} more
146
          </button>
147
        </div>
148
      ) : null}
149
    </>
150
  );
151
}