● Zion Boggan zionboggan.com ↗

Redesign R2: SearchFilterBar on members/bills/votes/spending + CiteThis (APA/MLA/Chicago + permalink + official .gov source) on records & list pages

fb422e2   Zion Boggan committed on Jun 27, 2026 (1 month ago)
FRONTEND_NOTES.md +88 -2
@@ -1,11 +1,97 @@
# Docket - Frontend Notes
-Wave F2 + F3 + F4 + F5 + R1 build log. All surfaces consume the live read-API
+Wave F2 + F3 + F4 + F5 + R1 + R2 build log. All surfaces consume the live read-API
(`app/api/*` → `lib/feed.ts` → SQLite at `data/docket.db`). No mock data on
the deep surfaces; `lib/mock-data.ts` remains only for the Phase-0 lint
reference and is not imported by any F2/F3/F4/F5 page.
-## R1 (this wave) - UX/QoL redesign foundation (Claude)
+## R2 (2026-06-27) - Search/filter on every list page + first-class Cite This (Claude)
+
+### Summary
+Wave R2 delivers the two owner-priority features from docs/REDESIGN.md §3 and §2.
+QA CLEAN (17 routes, zero overflow, zero hydration errors).
+
+### 1. Search/filter bar - every list page
+
+**New shared component: `components/search-filter-bar.tsx`**
+
+`SearchFilterBar` - RSC-compatible visual shell (no `"use client"`). Wraps filter
+fields in a Card, provides a responsive 1→2→4 col grid, and a result-count +
+clear-filters slot. Sub-components:
+- `FilterField` - labeled wrapper (label above, never placeholder-only; a11y Rule §Search)
+- `FilterInput` - `min-h-[52px]`, `text-[18px]` search box (ACCESSIBILITY.md Rule #2, §Search)
+- `FilterSelect` - `min-h-[44px]` select (Rule #3)
+- `FilterSubmit` - submit button for server-side forms
+
+Pages updated:
+| Page | Filter type | Fields |
+|---|---|---|
+| `/members` | Client-side (MembersBrowser) | Name search (18px/52px), state, chamber, party |
+| `/bills` | Server-side GET form | Title/number search, policy area, congress |
+| `/votes` | Server-side GET form | Keyword search (result+question), chamber |
+| `/spending` | Server-side GET form | Agency/recipient/program search |
+| `/executive` | Client-side (ExecutiveBrowser) | Title/agency search, type (pre-existing, unchanged) |
+
+**lib/feed.ts additions:**
+- `VoteFilter` type + `buildVotesWhere()` (pushes chamber/keyword filter into SQL)
+- `getVoteCountFiltered(filter)` - cheap COUNT(*) for the result eyebrow
+- `getVotesFiltered(filter, limit, offset)` - filtered paginated list (35k+ rows)
+- `getAwardsFiltered(q?)` - keyword filter on agency/recipient/program
+
+All filter forms use `method="get"` (server-side, bookmark-safe, no JS required).
+Members and executive remain client-side (smaller in-memory sets, already loaded).
+
+### 2. Cite This - first-class citation component
+
+**New component: `components/cite-this.tsx`** (`"use client"`)
+
+Generates APA, MLA, and Chicago citations with:
+- Tab selector (role="tablist/tab", keyboard arrow-key nav)
+- Copy-to-clipboard button (aria-label, min-h-[44px], "Copied ✓" confirmation)
+- Permalink (usdocket.org URL) + official .gov source in every citation (neutrality rule #1)
+- Accessed date computed entirely client-side via `useEffect` (hydration-safe: SSR shows
+ "Loading citation…" placeholder, client hydrates with real date)
+- Works in Reference / Dark / Plain themes (CSS tokens only, no hardcoded colors)
+
+**Citation formats (example for a Senate vote):**
+
+APA:
+```
+Docket. (2026). SENATE Roll-Call Vote - Cloture on the Motion to Proceed (Jan 10, 2025) [Data set]. U.S. Congress. Retrieved June 27, 2026, from https://usdocket.org/votes/1
+Official government source: https://www.senate.gov/...
+```
+
+MLA:
+```
+"SENATE Roll-Call Vote - Cloture on the Motion to Proceed (Jan 10, 2025)." Docket, United States Congress, 2026, usdocket.org/votes/1. Accessed 27 June 2026.
+Official government source: https://www.senate.gov/...
+```
+
+Chicago:
+```
+Docket. "SENATE Roll-Call Vote - Cloture on the Motion to Proceed (Jan 10, 2025)." United States Congress. 2026. https://usdocket.org/votes/1 (accessed June 27, 2026).
+Official government source: https://www.senate.gov/...
+```
+
+**Placed on:**
+- `/bill/[id]` - "Cite this record" after the official-record footer
+- `/votes/[id]` - "Cite this record" in the page footer
+- `/members/[id]` - "Cite this record" after the policy footer note
+- `/bills` - "Cite this page" in the footer
+- `/votes` - "Cite this page" in the footer
+- `/members` - "Cite this page" in the footer
+- `/spending` - "Cite this page" in the footer
+- `/executive` - "Cite this page" in the footer
+
+### Accessibility compliance
+- Search box: `min-h-[52px]` + `text-[18px]` (ACCESSIBILITY.md §Search: "≥50px tall, ≥18px font")
+- All controls: `min-h-[44px]` tap targets (Rule #3)
+- Labels above every input, never placeholder-only (§Search)
+- Focus rings on all tab buttons and copy button (Rule #4)
+- Tab bar keyboard navigation: ArrowLeft/ArrowRight cycles through APA/MLA/Chicago
+- Neutrality: every citation includes the official .gov source (rule #1); no adjectives
+
+## R1 (2026-06-26) - UX/QoL redesign foundation (Claude)
### Summary
Wave R1 delivers the four foundation changes from docs/REDESIGN.md. QA CLEAN (17 routes, zero overflow, zero hydration errors).
_ops/qa-view/members_r2.png +0 -0
Binary file not shown
app/bill/[id]/page.tsx +12 -0
@@ -10,6 +10,7 @@ import { MemberAvatar } from "@/components/member-avatar";
import { RiderFlag, ChangeTag } from "@/components/rider-flag";
import { getBillDetail, type BillDiffChange, type BillAmendment } from "@/lib/feed";
import { formatDate } from "@/lib/format";
+import { CiteThis } from "@/components/cite-this";
export const revalidate = 300;
@@ -325,6 +326,17 @@ export default function BillPage({
No comment, reaction, or share surface appears on this page, by policy.
</p>
</div>
+
+ {/* Cite this record */}
+ <div className="mt-8">
+ <p className="label mb-4 text-ash">Cite this record</p>
+ <CiteThis
+ kind="bill"
+ title={`${bill.number} - ${bill.title}`}
+ permalink={`https://usdocket.org/bill/${params.id}`}
+ sourceUrl={bill.sourceUrl}
+ />
+ </div>
</div>
);
}
app/bills/page.tsx +108 -66
@@ -4,8 +4,22 @@ import { Card, PageHeader, SectionHeader } from "@/components/ui";
import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { StatusPill, classifyStatus } from "@/components/status-pill";
-import { getBills, getBillsCount, getPolicyAreaLabels, getBillCongresses, type BillListItem } from "@/lib/feed";
+import {
+ getBills,
+ getBillsCount,
+ getPolicyAreaLabels,
+ getBillCongresses,
+ type BillListItem,
+} from "@/lib/feed";
import { formatDate } from "@/lib/format";
+import {
+ SearchFilterBar,
+ FilterField,
+ FilterInput,
+ FilterSelect,
+ FilterSubmit,
+} from "@/components/search-filter-bar";
+import { CiteThis } from "@/components/cite-this";
export const revalidate = 300;
@@ -23,7 +37,11 @@ function asString(v: string | string[] | undefined): string | undefined {
export default function BillsPage({
searchParams,
}: {
- searchParams: { policyArea?: string | string[]; q?: string | string[]; congress?: string | string[] };
+ searchParams: {
+ policyArea?: string | string[];
+ q?: string | string[];
+ congress?: string | string[];
+ };
}) {
const policyArea = asString(searchParams.policyArea);
const q = asString(searchParams.q);
@@ -43,10 +61,21 @@ export default function BillsPage({
const policyAreas = getPolicyAreaLabels();
const congresses = getBillCongresses();
+ const isFiltered = !!(policyArea || q || Number.isFinite(congressNum));
+
const activeChips: Array<{ label: string; href: string }> = [];
- if (policyArea) activeChips.push({ label: `Area: ${policyArea}`, href: dropParam(searchParams, "policyArea") });
- if (q) activeChips.push({ label: `Search: ${q}`, href: dropParam(searchParams, "q") });
- if (Number.isFinite(congressNum)) activeChips.push({ label: `Congress: ${congressNum}`, href: dropParam(searchParams, "congress") });
+ if (policyArea)
+ activeChips.push({
+ label: `Area: ${policyArea}`,
+ href: dropParam(searchParams, "policyArea"),
+ });
+ if (q)
+ activeChips.push({ label: `Search: ${q}`, href: dropParam(searchParams, "q") });
+ if (Number.isFinite(congressNum))
+ activeChips.push({
+ label: `Congress: ${congressNum}`,
+ href: dropParam(searchParams, "congress"),
+ });
return (
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
@@ -63,7 +92,7 @@ export default function BillsPage({
lead="Bills on record, newest action first. Filter by policy area, congress, or text. Each row links to its sponsor, status timeline, and the official record."
/>
- {/* Active filters + clear */}
+ {/* Active filter chips */}
{activeChips.length > 0 ? (
<div className="mt-6 flex flex-wrap items-center gap-2">
{activeChips.map((c) => (
@@ -84,57 +113,58 @@ export default function BillsPage({
</div>
) : null}
- {/* Filters */}
- <form method="get" className="mt-6 grid gap-3 sm:grid-cols-[1fr_auto_auto]">
- <label className="block">
- <span className="label mb-1 block">Search text</span>
- <input
- type="search"
- name="q"
- defaultValue={q ?? ""}
- placeholder="Bill number or title"
- className="focus-ring w-full rounded-[3px] border border-line bg-surface px-3 py-2 font-mono text-[13px] text-paper placeholder:text-ash-2"
- />
- </label>
- <label className="block">
- <span className="label mb-1 block">Policy area</span>
- <select
- name="policyArea"
- defaultValue={policyArea ?? ""}
- className="focus-ring min-h-[44px] w-full rounded-[3px] border border-line bg-surface px-3 py-2 font-mono text-[13px] text-paper sm:w-56"
- >
- <option value="">Any</option>
- {policyAreas.map((p) => (
- <option key={p} value={p}>{p}</option>
- ))}
- </select>
- </label>
- <label className="block sm:self-end">
- <span className="sr-only">Congress</span>
- <select
- name="congress"
- defaultValue={Number.isFinite(congressNum) ? String(congressNum) : ""}
- className="focus-ring min-h-[44px] w-full rounded-[3px] border border-line bg-surface px-3 py-2 font-mono text-[13px] text-paper sm:w-32"
- >
- <option value="">Any congress</option>
- {congresses.map((c) => (
- <option key={c} value={c}>{c}th</option>
- ))}
- </select>
- </label>
- <div className="sm:self-end">
- <button
- type="submit"
- className="focus-ring label inline-flex min-h-[44px] w-full items-center justify-center rounded-[3px] border border-line bg-surface px-4 text-paper hover:bg-graphite sm:w-auto"
+ {/* Search/filter - server-side GET form, consistent SearchFilterBar shell */}
+ <div className="mt-6">
+ <form method="get" action="/bills" className="contents">
+ <SearchFilterBar
+ count={billsTotal}
+ total={getBillsCount()}
+ countLabel="bill"
+ clearHref={isFiltered ? "/bills" : undefined}
>
- Filter
- </button>
- </div>
- </form>
+ <FilterField label="Search bills" span="2">
+ <FilterInput
+ name="q"
+ defaultValue={q ?? ""}
+ placeholder="Bill number or title…"
+ aria-label="Search bills by number or title"
+ />
+ </FilterField>
+ <FilterField label="Policy area">
+ <FilterSelect
+ name="policyArea"
+ defaultValue={policyArea ?? ""}
+ aria-label="Filter by policy area"
+ options={[
+ { value: "", label: "Any area" },
+ ...policyAreas.map((p) => ({ value: p, label: p })),
+ ]}
+ />
+ </FilterField>
+ <FilterField label="Congress">
+ <FilterSelect
+ name="congress"
+ defaultValue={
+ Number.isFinite(congressNum) ? String(congressNum) : ""
+ }
+ aria-label="Filter by Congress number"
+ options={[
+ { value: "", label: "Any congress" },
+ ...congresses.map((c) => ({
+ value: String(c),
+ label: `${c}th`,
+ })),
+ ]}
+ />
+ </FilterField>
+ <FilterSubmit />
+ </SearchFilterBar>
+ </form>
+ </div>
<div className="mt-8">
<SectionHeader
- eyebrow={`${billsTotal.toLocaleString()} bill${billsTotal === 1 ? "" : "s"} · showing ${bills.length}`}
+ eyebrow={`${billsTotal.toLocaleString()} bill${billsTotal === 1 ? "" : "s"}${isFiltered ? " match" : ""} · showing ${bills.length}`}
title={policyArea ? policyArea : "On record"}
/>
{bills.length === 0 ? (
@@ -149,10 +179,21 @@ export default function BillsPage({
</div>
<div className="mt-12 border-t border-line pt-6">
- <p className="label text-ash-2">
+ <p className="label mb-6 text-ash-2">
Bills from congress.gov. Order is by last action date - no editorial
weighting. No comment surface appears here, by policy.
</p>
+ <p className="label mb-4 text-ash">Cite this page</p>
+ <CiteThis
+ kind="page"
+ title={policyArea ? `Bills - ${policyArea}` : "Bills - Browse all bills"}
+ permalink={
+ policyArea
+ ? `https://usdocket.org/bills?policyArea=${encodeURIComponent(policyArea)}`
+ : "https://usdocket.org/bills"
+ }
+ sourceUrl="https://www.congress.gov/browse/bills"
+ />
</div>
</div>
);
@@ -183,23 +224,24 @@ function BillsTable({ bills }: { bills: BillListItem[] }) {
</span>
</span>
),
- area:
- b.policyArea ? (
- <Link
- href={`/bills?policyArea=${encodeURIComponent(b.policyArea)}`}
- className="focus-ring rounded-sm text-ash underline underline-offset-4 hover:text-paper"
- >
- {b.policyArea}
- </Link>
- ) : (
- <span className="text-ash-2">-</span>
- ),
+ area: b.policyArea ? (
+ <Link
+ href={`/bills?policyArea=${encodeURIComponent(b.policyArea)}`}
+ className="focus-ring rounded-sm text-ash underline underline-offset-4 hover:text-paper"
+ >
+ {b.policyArea}
+ </Link>
+ ) : (
+ <span className="text-ash-2">-</span>
+ ),
status: (
<StatusPill variant={classifyStatus(b.status)}>
{b.status || "Introduced"}
</StatusPill>
),
- updated: <span className="tabular-nums text-ash">{formatDate(b.updatedAt)}</span>,
+ updated: (
+ <span className="tabular-nums text-ash">{formatDate(b.updatedAt)}</span>
+ ),
source: <SourceLink url={b.sourceUrl} />,
},
}));
app/executive/page.tsx +12 -0
@@ -3,6 +3,7 @@ import { Card, PageHeader, SectionHeader } from "@/components/ui";
import { SourceLink } from "@/components/source-link";
import { ExecutiveBrowser } from "@/components/executive-browser";
import { getExecutive } from "@/lib/feed";
+import { CiteThis } from "@/components/cite-this";
export const metadata: Metadata = {
title: "Federal Register",
@@ -55,6 +56,17 @@ export default function ExecutivePage() {
<p className="label mt-8 text-ash-2">
Source: federalregister.gov API. Types and document numbers are recorded as published; no characterization.
</p>
+
+ {/* Cite this page */}
+ <div className="mt-12 border-t border-line pt-8">
+ <p className="label mb-4 text-ash">Cite this page</p>
+ <CiteThis
+ kind="page"
+ title="Federal Register - Executive actions"
+ permalink="https://usdocket.org/executive"
+ sourceUrl="https://www.federalregister.gov/"
+ />
+ </div>
</div>
);
}
app/members/[id]/page.tsx +12 -0
@@ -15,6 +15,7 @@ import {
} from "@/lib/feed";
import { MemberVoteYear } from "@/components/member-vote-year";
import { formatDate } from "@/lib/format";
+import { CiteThis } from "@/components/cite-this";
export const revalidate = 300;
@@ -231,6 +232,17 @@ export default function MemberDetailPage({
Vote positions and sponsorships from clerk.house.gov, senate.gov, and congress.gov. No comment or reaction surface appears here, by policy.
</p>
</div>
+
+ {/* Cite this record */}
+ <div className="mt-8">
+ <p className="label mb-4 text-ash">Cite this record</p>
+ <CiteThis
+ kind="member"
+ title={`${member.name} (${member.partyCode}-${member.state}) - Congressional Record`}
+ permalink={`https://usdocket.org${member.href}`}
+ sourceUrl={member.sourceUrl}
+ />
+ </div>
</div>
);
}
app/members/page.tsx +12 -0
@@ -6,6 +6,7 @@ import {
} from "@/lib/feed";
import { Card, PageHeader } from "@/components/ui";
import { MembersBrowser } from "@/components/members-browser";
+import { CiteThis } from "@/components/cite-this";
export const metadata: Metadata = {
title: "Members of Congress",
@@ -50,6 +51,17 @@ export default function MembersPage() {
/>
</div>
</div>
+
+ {/* Cite this page */}
+ <div className="mt-12 border-t border-line pt-8">
+ <p className="label mb-4 text-ash">Cite this page</p>
+ <CiteThis
+ kind="page"
+ title="Members of Congress - Roster"
+ permalink="https://usdocket.org/members"
+ sourceUrl="https://bioguide.congress.gov/"
+ />
+ </div>
</div>
);
}
app/spending/page.tsx +62 -6
@@ -1,9 +1,22 @@
import type { Metadata } from "next";
-import { getAwards, getFiscal, getApportionments, type Award, type Apportionment } from "@/lib/feed";
+import {
+ getAwardsFiltered,
+ getFiscal,
+ getApportionments,
+ type Award,
+ type Apportionment,
+} from "@/lib/feed";
import { Card, PageHeader, SectionHeader } from "@/components/ui";
import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { formatUsd, formatDate } from "@/lib/format";
+import {
+ SearchFilterBar,
+ FilterField,
+ FilterInput,
+ FilterSubmit,
+} from "@/components/search-filter-bar";
+import { CiteThis } from "@/components/cite-this";
export const metadata: Metadata = {
title: "Spending",
@@ -13,6 +26,11 @@ export const metadata: Metadata = {
export const revalidate = 300;
+function asStr(v: string | string[] | undefined): string | undefined {
+ if (Array.isArray(v)) return v[0];
+ return v;
+}
+
/** Impound status → muted semantic tone (released = sage, delayed = hold,
* withheld = clay). Never vivid; a withheld status is recorded as fact. */
function apportionmentColor(status: string): string {
@@ -22,11 +40,17 @@ function apportionmentColor(status: string): string {
return "var(--dk-yea)";
}
-export default function SpendingPage() {
- const awards = getAwards();
+export default function SpendingPage({
+ searchParams,
+}: {
+ searchParams: { q?: string | string[] };
+}) {
+ const q = asStr(searchParams.q);
+ const awards = getAwardsFiltered(q);
const fiscal = getFiscal();
const apportionments = getApportionments();
const totalAwards = awards.reduce((n, a) => n + a.amount, 0);
+ const isFiltered = !!q;
const withheld = apportionments.filter((a) =>
(a.status || "").toLowerCase().startsWith("withhold")
@@ -50,6 +74,27 @@ export default function SpendingPage() {
lead="Awards from USAspending.gov; debt, outlays, and cash from the Daily Treasury Statement. Figures only - no characterization."
/>
+ {/* Awards search filter - server-side GET form */}
+ <div className="mt-8">
+ <form method="get" action="/spending" className="contents">
+ <SearchFilterBar
+ count={awards.length}
+ countLabel="award"
+ clearHref={isFiltered ? "/spending" : undefined}
+ >
+ <FilterField label="Search awards" span="2">
+ <FilterInput
+ name="q"
+ defaultValue={q ?? ""}
+ placeholder="Agency, recipient, or program…"
+ aria-label="Search federal awards by agency, recipient, or program"
+ />
+ </FilterField>
+ <FilterSubmit />
+ </SearchFilterBar>
+ </form>
+ </div>
+
{/* fiscal totals - calm empty state while the Treasury ingest lands */}
{fiscal.length > 0 ? (
<div className="mt-8 grid gap-4 sm:grid-cols-3">
@@ -82,16 +127,16 @@ export default function SpendingPage() {
<SectionHeader
eyebrow="Federal awards"
title="Contracts, grants, and assistance"
- description={`${awards.length} awards from USAspending.gov, totaling ${formatUsd(
+ description={`${awards.length} award${awards.length === 1 ? "" : "s"}${isFiltered ? " match" : ""} from USAspending.gov, totaling ${formatUsd(
totalAwards,
{ compact: true }
)}. Largest first.`}
/>
{awards.length === 0 ? (
<Card className="p-8 text-center">
- <p className="display text-2xl text-paper">No awards on record.</p>
+ <p className="display text-2xl text-paper">No awards match.</p>
<p className="prose-lead mx-auto mt-3 max-w-md text-ash">
- USAspending.gov awards will appear here as the ingest runs.
+ Adjust the search above. Federal awards appear as the ingest runs.
</p>
</Card>
) : (
@@ -138,6 +183,17 @@ export default function SpendingPage() {
Source: apportionment-public.max.gov (OMB). Appropriated and apportioned are recorded figures; the gap is shown where one exists.
</p>
</div>
+
+ {/* Cite this page */}
+ <div className="mt-12 border-t border-line pt-8">
+ <p className="label mb-4 text-ash">Cite this page</p>
+ <CiteThis
+ kind="page"
+ title="Spending - Federal awards and apportionments"
+ permalink="https://usdocket.org/spending"
+ sourceUrl="https://www.usaspending.gov/"
+ />
+ </div>
</div>
);
}
app/votes/[id]/page.tsx +13 -0
@@ -11,6 +11,7 @@ import { PartyTag } from "@/components/party-tag";
import { MemberAvatar } from "@/components/member-avatar";
import { getVoteById, type VotePosition } from "@/lib/feed";
import { formatDate } from "@/lib/format";
+import { CiteThis } from "@/components/cite-this";
export const revalidate = 300;
@@ -48,6 +49,7 @@ export default function VoteDetailPage({
}: {
params: { id: string };
}) {
+ const voteUrlId = params.id; // raw URL param e.g. "123"
const numeric = parseInt(params.id.replace(/^vote:/, ""), 10);
const vote = Number.isFinite(numeric) ? getVoteById(numeric) : null;
if (!vote) notFound();
@@ -195,6 +197,17 @@ export default function VoteDetailPage({
</Card>
</div>
)}
+
+ {/* Cite this record */}
+ <div className="mt-12 border-t border-line pt-8">
+ <p className="label mb-4 text-ash">Cite this record</p>
+ <CiteThis
+ kind="vote"
+ title={`${vote.chamber} Roll-Call Vote - ${vote.question || vote.result || "Recorded vote"} (${formatDate(vote.date)})`}
+ permalink={`https://usdocket.org/votes/${voteUrlId}`}
+ sourceUrl={vote.sourceUrl}
+ />
+ </div>
</div>
);
}
app/votes/page.tsx +95 -14
@@ -4,22 +4,56 @@ import { Card, PageHeader, SectionHeader } from "@/components/ui";
import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { StatusPill, classifyStatus } from "@/components/status-pill";
-import { getVotes, getVoteCount } from "@/lib/feed";
+import {
+ getVotesFiltered,
+ getVoteCountFiltered,
+ type VoteFilter,
+} from "@/lib/feed";
import { formatDate } from "@/lib/format";
+import {
+ SearchFilterBar,
+ FilterField,
+ FilterInput,
+ FilterSelect,
+ FilterSubmit,
+} from "@/components/search-filter-bar";
+import { CiteThis } from "@/components/cite-this";
export const metadata: Metadata = {
title: "Votes",
description:
- "Recorded roll-call votes of the U.S. House and Senate. Every vote links to its official record.",
+ "Recorded roll-call votes of the U.S. House and Senate. Filter by chamber or keyword. Every vote links to its official record.",
};
export const revalidate = 300;
-export default function VotesPage() {
- // Fetch only what we render (100 rows) + a cheap COUNT(*) for the eyebrow.
- const votes = getVotes(100, 0);
- const totalVotes = getVoteCount();
- const shown = votes; // already limited to 100
+function asStr(v: string | string[] | undefined): string | undefined {
+ if (Array.isArray(v)) return v[0];
+ return v;
+}
+
+export default function VotesPage({
+ searchParams,
+}: {
+ searchParams: {
+ chamber?: string | string[];
+ q?: string | string[];
+ };
+}) {
+ const chamber = asStr(searchParams.chamber);
+ const q = asStr(searchParams.q);
+ const filter: VoteFilter = {
+ chamber: chamber || undefined,
+ q: q || undefined,
+ };
+
+ // Push filtering into SQL - only fetch the 100 we render.
+ const votes = getVotesFiltered(filter, 100, 0);
+ const totalFiltered = getVoteCountFiltered(filter);
+ const isFiltered = !!(chamber || q);
+
+ // Determine clear href.
+ const clearHref = "/votes";
return (
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
@@ -29,32 +63,79 @@ export default function VotesPage() {
lead="Each row is a recorded vote in the House or Senate - the question, the tally, and a link to the official roll call. Open any vote to see how every member voted."
/>
+ {/* Search/filter bar - server-side via GET form (fast on 35k+ rows). */}
+ <div className="mt-8">
+ <form method="get" action="/votes" className="contents">
+ <SearchFilterBar
+ count={votes.length < totalFiltered ? votes.length : totalFiltered}
+ total={totalFiltered}
+ countLabel="vote"
+ clearHref={isFiltered ? clearHref : undefined}
+ >
+ <FilterField label="Search votes" span="2">
+ <FilterInput
+ name="q"
+ defaultValue={q ?? ""}
+ placeholder="Keyword, bill, question…"
+ aria-label="Search votes by keyword or question"
+ />
+ </FilterField>
+ <FilterField label="Chamber">
+ <FilterSelect
+ name="chamber"
+ defaultValue={chamber ?? ""}
+ aria-label="Filter by chamber"
+ options={[
+ { value: "", label: "Both chambers" },
+ { value: "senate", label: "Senate" },
+ { value: "house", label: "House" },
+ ]}
+ />
+ </FilterField>
+ <FilterSubmit />
+ </SearchFilterBar>
+ </form>
+ </div>
+
<div className="mt-8">
<SectionHeader
- eyebrow={`${totalVotes.toLocaleString()} votes`}
+ eyebrow={`${totalFiltered.toLocaleString()} vote${totalFiltered === 1 ? "" : "s"}${isFiltered ? " match" : ""} · showing ${votes.length}`}
title="Recorded votes"
description="Newest action first. A vote with no Yea/Nay tally passed by voice or unanimous consent - no individual record exists for those."
/>
{votes.length === 0 ? (
<Card className="p-8 text-center">
- <p className="display text-2xl text-paper">No votes on record.</p>
+ <p className="display text-2xl text-paper">No votes match.</p>
<p className="prose-lead mx-auto mt-3 max-w-md text-ash">
- Roll-call votes appear here as the House and Senate record them.
+ Adjust the filter or search above. The roll-call register covers
+ recorded House and Senate votes.
</p>
</Card>
) : (
- <VotesTable votes={shown} />
+ <VotesTable votes={votes} />
)}
<p className="label mt-4 text-ash-2">
- Source: clerk.house.gov and senate.gov roll-call XML. Figures only - no characterization.
+ Source: clerk.house.gov and senate.gov roll-call XML. Figures only -
+ no characterization.
</p>
</div>
+
+ {/* Cite this page */}
+ <div className="mt-12 border-t border-line pt-8">
+ <p className="label mb-4 text-ash">Cite this page</p>
+ <CiteThis
+ kind="page"
+ title="Votes - Roll-call register"
+ permalink="https://usdocket.org/votes"
+ sourceUrl="https://clerk.house.gov/evs/ https://www.senate.gov/legislative/votes_new.htm"
+ />
+ </div>
</div>
);
}
/** Recorded votes as a responsive table (>=md) / stacked cards (<md). */
-function VotesTable({ votes }: { votes: ReturnType<typeof getVotes> }) {
+function VotesTable({ votes }: { votes: ReturnType<typeof getVotesFiltered> }) {
const columns: Column[] = [
{ key: "date", header: "Date" },
{ key: "chamber", header: "Chamber", hideOnCard: true },
@@ -86,7 +167,7 @@ function VotesTable({ votes }: { votes: ReturnType<typeof getVotes> }) {
),
tally: (
<span className="tabular-nums text-paper">
- {v.recorded ? `${v.yea}\u2013${v.nay}` : "-"}
+ {v.recorded ? `${v.yea}-${v.nay}` : "-"}
</span>
),
record: <SourceLink url={v.sourceUrl} />,
components/cite-this.tsx +275 -0
@@ -0,0 +1,275 @@
+/**
+ * CiteThis - first-class citation component (docs/REDESIGN.md §"Cite this").
+ *
+ * Generates ready-to-use citations in APA, MLA, and Chicago styles for any
+ * Docket record or page. Each style includes the usdocket.org permalink AND
+ * the underlying official .gov source (neutrality rule #1).
+ *
+ * Placement:
+ * • Detail pages (bill, vote, member): after main content, before footer
+ * • List pages: in the footer area as "Cite this page"
+ *
+ * Accessibility (docs/ACCESSIBILITY.md):
+ * • Tab list: role="tablist", role="tab", aria-selected (keyboard: arrow keys)
+ * • Copy button: aria-label, min-h-[44px] tap target (Rule #3)
+ * • Focus rings on all controls (Rule #4)
+ * • Works in Reference / Dark / Plain modes (CSS tokens only)
+ *
+ * Hydration safety: accessed date is computed entirely client-side via
+ * useEffect so SSR and client never mismatch.
+ */
+
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { clsx } from "@/lib/cx";
+
+export type CiteKind = "bill" | "vote" | "member" | "award" | "page";
+type CiteFormat = "apa" | "mla" | "chicago";
+
+export interface CiteThisProps {
+ /** Descriptive kind of record (informational only). */
+ kind?: CiteKind;
+ /** Display title of the resource (official title, question, member name, etc.). */
+ title: string;
+ /** Full permalink: https://usdocket.org/... */
+ permalink: string;
+ /** Official .gov source URL (neutrality rule #1 - always surface the primary source). */
+ sourceUrl: string;
+ /** Year of publication / action (defaults to current calendar year). */
+ year?: number;
+}
+
+/** Format a Date as "Month D, YYYY" - APA/Chicago style. */
+function longDate(d: Date): string {
+ return d.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+}
+
+/** Format a Date as "D Month YYYY" - MLA style. */
+function mlaDate(d: Date): string {
+ const day = d.getDate();
+ const month = d.toLocaleDateString("en-US", { month: "long" });
+ const year = d.getFullYear();
+ return `${day} ${month} ${year}`;
+}
+
+/** Strip the https:// scheme for MLA bare-URL format. */
+function bareUrl(url: string): string {
+ return url.replace(/^https?:\/\//, "");
+}
+
+function buildCitations(
+ title: string,
+ permalink: string,
+ sourceUrl: string,
+ year: number,
+ today: Date
+): Record<CiteFormat, string> {
+ const accessed = longDate(today);
+ const accessedMla = mlaDate(today);
+
+ const apa = [
+ `Docket. (${year}). ${title} [Data set]. U.S. Congress. Retrieved ${accessed}, from ${permalink}`,
+ `Official government source: ${sourceUrl}`,
+ ].join("\n");
+
+ const mla = [
+ `"${title}." Docket, United States Congress, ${year}, ${bareUrl(permalink)}. Accessed ${accessedMla}.`,
+ `Official government source: ${sourceUrl}`,
+ ].join("\n");
+
+ const chicago = [
+ `Docket. "${title}." United States Congress. ${year}. ${permalink} (accessed ${accessed}).`,
+ `Official government source: ${sourceUrl}`,
+ ].join("\n");
+
+ return { apa, mla, chicago };
+}
+
+const FORMAT_LABELS: Record<CiteFormat, string> = {
+ apa: "APA",
+ mla: "MLA",
+ chicago: "Chicago",
+};
+const FORMATS: CiteFormat[] = ["apa", "mla", "chicago"];
+
+export function CiteThis({
+ title,
+ permalink,
+ sourceUrl,
+ year,
+}: CiteThisProps) {
+ const [mounted, setMounted] = useState(false);
+ const [active, setActive] = useState<CiteFormat>("apa");
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const today = mounted ? new Date() : null;
+ const pubYear = year ?? (today?.getFullYear() ?? new Date().getFullYear());
+ const citations = today
+ ? buildCitations(title, permalink, sourceUrl, pubYear, today)
+ : null;
+
+ const handleCopy = useCallback(async () => {
+ if (!citations) return;
+ try {
+ await navigator.clipboard.writeText(citations[active]);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Clipboard API unavailable (e.g. HTTP, old browser); graceful degradation.
+ }
+ }, [citations, active]);
+
+ // Keyboard navigation for the tab bar.
+ const handleTabKeyDown = useCallback(
+ (e: React.KeyboardEvent<HTMLButtonElement>, idx: number) => {
+ let next: number | null = null;
+ if (e.key === "ArrowRight") next = (idx + 1) % FORMATS.length;
+ if (e.key === "ArrowLeft") next = (idx - 1 + FORMATS.length) % FORMATS.length;
+ if (next !== null) {
+ e.preventDefault();
+ setActive(FORMATS[next]);
+ // Move focus to the new tab via a rAF to let state settle.
+ requestAnimationFrame(() => {
+ (
+ document.querySelector(
+ `[data-cite-tab="${FORMATS[next!]}"]`
+ ) as HTMLButtonElement | null
+ )?.focus();
+ });
+ }
+ },
+ []
+ );
+
+ return (
+ <section
+ aria-label="Cite this record"
+ className="rounded-[3px] border border-line bg-surface"
+ >
+ {/* Header row */}
+ <div className="flex items-center justify-between border-b border-line px-4 py-3 sm:px-5">
+ <p className="label text-ash">Cite this</p>
+ {/* Tab bar */}
+ <div
+ role="tablist"
+ aria-label="Citation format"
+ className="flex gap-1"
+ >
+ {FORMATS.map((fmt, idx) => (
+ <button
+ key={fmt}
+ role="tab"
+ data-cite-tab={fmt}
+ aria-selected={active === fmt}
+ onClick={() => setActive(fmt)}
+ onKeyDown={(e) => handleTabKeyDown(e, idx)}
+ tabIndex={active === fmt ? 0 : -1}
+ className={clsx(
+ "focus-ring rounded-[2px] px-3 py-1.5 font-mono text-[12px] transition-colors",
+ "min-h-[36px]",
+ active === fmt
+ ? "bg-graphite text-paper"
+ : "text-ash hover:bg-graphite hover:text-paper"
+ )}
+ >
+ {FORMAT_LABELS[fmt]}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Citation text */}
+ <div className="px-4 py-4 sm:px-5">
+ {mounted && citations ? (
+ <pre
+ role="tabpanel"
+ aria-label={`${FORMAT_LABELS[active]} citation`}
+ className="whitespace-pre-wrap break-words font-mono text-[13px] leading-relaxed text-paper"
+ >
+ {citations[active]}
+ </pre>
+ ) : (
+ <p className="font-mono text-[13px] text-ash-2">
+ Loading citation…
+ </p>
+ )}
+ </div>
+
+ {/* Copy row */}
+ <div className="flex items-center justify-between border-t border-line px-4 py-3 sm:px-5">
+ <p className="label text-ash-2">
+ {/* Nudge for educators/students - neutral phrasing */}
+ APA · MLA · Chicago - copy and paste into your document or citation manager.
+ </p>
+ <button
+ type="button"
+ onClick={handleCopy}
+ disabled={!mounted}
+ aria-label={`Copy ${FORMAT_LABELS[active]} citation to clipboard`}
+ className={clsx(
+ "focus-ring label inline-flex min-h-[44px] items-center gap-2 rounded-[3px] border border-line px-3 py-2",
+ "transition-colors",
+ copied
+ ? "border-yea text-yea"
+ : "text-ash hover:bg-graphite hover:text-paper"
+ )}
+ style={copied ? { borderColor: "var(--dk-yea)", color: "var(--dk-yea)" } : undefined}
+ >
+ {copied ? (
+ <>
+ <CopyIcon done />
+ Copied
+ </>
+ ) : (
+ <>
+ <CopyIcon />
+ Copy {FORMAT_LABELS[active]}
+ </>
+ )}
+ </button>
+ </div>
+ </section>
+ );
+}
+
+function CopyIcon({ done = false }: { done?: boolean }) {
+ return done ? (
+ <svg
+ aria-hidden="true"
+ width="14"
+ height="14"
+ viewBox="0 0 14 14"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <polyline points="2 7 6 11 12 3" />
+ </svg>
+ ) : (
+ <svg
+ aria-hidden="true"
+ width="14"
+ height="14"
+ viewBox="0 0 14 14"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <rect x="4" y="4" width="8" height="8" rx="1" />
+ <path d="M2 9V2h7" />
+ </svg>
+ );
+}
components/members-browser.tsx +96 -137
@@ -5,6 +5,12 @@ import Link from "next/link";
import { Card } from "@/components/ui";
import { PartyTag } from "@/components/party-tag";
import { MemberAvatar } from "@/components/member-avatar";
+import {
+ SearchFilterBar,
+ FilterField,
+ FilterInput,
+ FilterSelect,
+} from "@/components/search-filter-bar";
import { clsx } from "@/lib/cx";
import type { MemberSummary } from "@/lib/feed";
@@ -83,7 +89,12 @@ export function MembersBrowser({
// When no search/filter is active, hide historical-without-photo
// unless the toggle is on. When any filter is active, show everything
// (the user is explicitly looking for something specific).
- if (!anyFilterActive && !showAllHistorical && !m.isCurrent && !m.photoUrl) {
+ if (
+ !anyFilterActive &&
+ !showAllHistorical &&
+ !m.isCurrent &&
+ !m.photoUrl
+ ) {
return false;
}
if (
@@ -116,98 +127,91 @@ export function MembersBrowser({
return (
<>
- {/* Filter bar - labeled selects + labeled search (a11y §Search). */}
- <Card className="p-4 sm:p-5">
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
- <Field label="Search by name">
- <input
- type="search"
- value={q}
- onChange={(e) => setQ(e.target.value)}
- placeholder="Name or ID"
- aria-label="Search members by name"
- className="focus-ring w-full rounded-[2px] border border-line bg-ink px-3 py-2 font-mono text-[13px] text-paper placeholder:text-ash-2"
- />
- </Field>
- <Field label="State">
- <Select
- value={state}
- onChange={setState}
- ariaLabel="Filter by state"
- options={[
- { value: "", label: "All states" },
- ...states.map((s) => ({ value: s, label: s })),
- ]}
- />
- </Field>
- <Field label="Chamber">
- <Select
- value={chamber}
- onChange={setChamber}
- ariaLabel="Filter by chamber"
- options={[
- { value: "", label: "Both chambers" },
- { value: "senate", label: "Senate" },
- { value: "house", label: "House" },
- ]}
- />
- </Field>
- <Field label="Party">
- <Select
- value={party}
- onChange={setParty}
- ariaLabel="Filter by party"
- options={[
- { value: "", label: "All parties" },
- { value: "D", label: "Democrat" },
- { value: "R", label: "Republican" },
- { value: "I", label: "Independent" },
- ]}
- />
- </Field>
- </div>
- <div className="mt-3 flex items-center justify-between gap-4">
- <p className="label text-ash-2">
- {filtered.length} member{filtered.length === 1 ? "" : "s"} shown
- {!anyFilterActive && !showAllHistorical && hiddenCount > 0 ? (
- <span>
- {" · "}Showing current Congress and members with a recorded
- photo
- </span>
- ) : null}
- </p>
- <div className="flex items-center gap-3">
- {!anyFilterActive && hiddenCount > 0 && (
- <button
- type="button"
- onClick={handleShowAll}
- disabled={loadingAll}
- className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper disabled:opacity-50"
- >
- {loadingAll
- ? "Loading…"
- : showAllHistorical
- ? "Hide historical members without a photo"
- : `Show all ${hiddenCount.toLocaleString()} historical members`}
- </button>
- )}
- {anyFilterActive && (
- <button
- type="button"
- onClick={() => {
- setQ("");
- setState("");
- setChamber("");
- setParty("");
- }}
- className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper"
- >
- Clear filters
- </button>
- )}
- </div>
+ {/* Search/filter bar - labeled controls, ≥44px targets, ≥18px search
+ text (ACCESSIBILITY.md §Search, Rule #2, Rule #3). Client-side
+ filtering over the server-seeded default-visible set. */}
+ <SearchFilterBar
+ count={filtered.length}
+ countLabel="member"
+ onClear={
+ anyFilterActive
+ ? () => {
+ setQ("");
+ setState("");
+ setChamber("");
+ setParty("");
+ }
+ : undefined
+ }
+ >
+ <FilterField label="Search by name" span="2">
+ <FilterInput
+ value={q}
+ onChange={setQ}
+ placeholder="Name or bioguide ID…"
+ aria-label="Search members by name or bioguide ID"
+ />
+ </FilterField>
+ <FilterField label="State">
+ <FilterSelect
+ value={state}
+ onChange={setState}
+ aria-label="Filter by state"
+ options={[
+ { value: "", label: "All states" },
+ ...states.map((s) => ({ value: s, label: s })),
+ ]}
+ />
+ </FilterField>
+ <FilterField label="Chamber">
+ <FilterSelect
+ value={chamber}
+ onChange={setChamber}
+ aria-label="Filter by chamber"
+ options={[
+ { value: "", label: "Both chambers" },
+ { value: "senate", label: "Senate" },
+ { value: "house", label: "House" },
+ ]}
+ />
+ </FilterField>
+ <FilterField label="Party">
+ <FilterSelect
+ value={party}
+ onChange={setParty}
+ aria-label="Filter by party"
+ options={[
+ { value: "", label: "All parties" },
+ { value: "D", label: "Democrat" },
+ { value: "R", label: "Republican" },
+ { value: "I", label: "Independent" },
+ ]}
+ />
+ </FilterField>
+ </SearchFilterBar>
+
+ {/* Historical toggle - only shown when no filter is active */}
+ {!anyFilterActive && hiddenCount > 0 && (
+ <div className="mt-3 flex justify-end">
+ <button
+ type="button"
+ onClick={handleShowAll}
+ disabled={loadingAll}
+ className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper disabled:opacity-50"
+ >
+ {loadingAll
+ ? "Loading…"
+ : showAllHistorical
+ ? "Hide historical members without a photo"
+ : `Show all ${hiddenCount.toLocaleString()} historical members`}
+ </button>
</div>
- </Card>
+ )}
+ {!anyFilterActive && !showAllHistorical && hiddenCount > 0 && (
+ <p className="label mt-1.5 text-right text-ash-2">
+ Showing current Congress and members with a recorded photo
+ </p>
+ )}
{/* Roster grid. */}
<div className="mt-6">
@@ -255,48 +259,3 @@ export function MembersBrowser({
</>
);
}
-
-function Field({
- label,
- children,
-}: {
- label: string;
- children: React.ReactNode;
-}) {
- return (
- <label className="block">
- <span className="label mb-1.5 block">{label}</span>
- {children}
- </label>
- );
-}
-
-function Select({
- value,
- onChange,
- ariaLabel,
- options,
-}: {
- value: string;
- onChange: (v: string) => void;
- ariaLabel: string;
- options: Array<{ value: string; label: string }>;
-}) {
- return (
- <select
- value={value}
- onChange={(e) => onChange(e.target.value)}
- aria-label={ariaLabel}
- className={clsx(
- "focus-ring w-full rounded-[2px] border border-line bg-ink px-3 py-2",
- "font-mono text-[13px] text-paper"
- )}
- >
- {options.map((o) => (
- <option key={o.value} value={o.value} className="bg-surface text-paper">
- {o.label}
- </option>
- ))}
- </select>
- );
-}
components/search-filter-bar.tsx +209 -0
@@ -0,0 +1,209 @@
+/**
+ * SearchFilterBar - shared accessible filter-card shell used across all list
+ * pages (members, bills, votes, spending, executive, schedule).
+ *
+ * Works for both server-side forms (method="get") and client-side controlled
+ * components. Consumers build the field children; this component provides:
+ * • Card wrapper with consistent padding
+ * • Grid layout for filter fields (responsive, 1→2→4 cols)
+ * • Result count + optional clear slot
+ *
+ * Accessibility (docs/ACCESSIBILITY.md):
+ * • All inputs: min-h-[44px] tap targets (Rule #3)
+ * • Search input: min-h-[52px], font-size 18px, label above (not
+ * placeholder-only) - ACCESSIBILITY.md §Search / Rule #2
+ * • Focus rings on every control (Rule #4)
+ * • Works in Reference / Dark / Plain themes via CSS tokens
+ *
+ * No "use client" - this is a pure RSC-compatible component. Client-side
+ * callers (MembersBrowser) import it into their own client boundary.
+ */
+
+import { clsx } from "@/lib/cx";
+import { Card } from "@/components/ui";
+import type { ReactNode } from "react";
+
+/** Outer shell card with a responsive field grid and a result/clear bar. */
+export function SearchFilterBar({
+ children,
+ count,
+ total,
+ countLabel = "result",
+ onClear,
+ clearHref,
+ className,
+}: {
+ children: ReactNode;
+ /** Number of visible results (after filtering). */
+ count?: number;
+ /** Total unfiltered count - shown as "N of total". */
+ total?: number;
+ countLabel?: string;
+ /** Client-side clear callback - shows a "Clear filters" button. */
+ onClear?: () => void;
+ /** Server-side clear href - shows a "Clear filters" link. */
+ clearHref?: string;
+ className?: string;
+}) {
+ const showClear = !!(onClear || clearHref);
+ return (
+ <Card className={clsx("p-4 sm:p-5", className)}>
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">{children}</div>
+ {(count != null || showClear) && (
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-2">
+ {count != null && (
+ <p className="label text-ash-2">
+ {count.toLocaleString()}&nbsp;{countLabel}
+ {count !== 1 ? "s" : ""}
+ {total != null && count !== total
+ ? ` of ${total.toLocaleString()}`
+ : ""}
+ </p>
+ )}
+ {onClear && (
+ <button
+ type="button"
+ onClick={onClear}
+ className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper"
+ >
+ Clear filters
+ </button>
+ )}
+ {clearHref && !onClear && (
+ <a
+ href={clearHref}
+ className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper"
+ >
+ Clear filters
+ </a>
+ )}
+ </div>
+ )}
+ </Card>
+ );
+}
+
+/** Labeled field wrapper - label above, not placeholder-only (ACCESSIBILITY.md §Search). */
+export function FilterField({
+ label,
+ span,
+ children,
+}: {
+ label: string;
+ /** Number of grid columns to span (e.g. span="2" for wider search box). */
+ span?: "2";
+ children: ReactNode;
+}) {
+ return (
+ <label className={clsx("block", span === "2" && "sm:col-span-2")}>
+ <span className="label mb-1.5 block text-ash">{label}</span>
+ {children}
+ </label>
+ );
+}
+
+/** Standard text-search input (≥52px tall, 18px text - ACCESSIBILITY.md §Search). */
+export function FilterInput({
+ name,
+ value,
+ defaultValue,
+ onChange,
+ placeholder,
+ "aria-label": ariaLabel,
+}: {
+ name?: string;
+ value?: string;
+ defaultValue?: string;
+ onChange?: (v: string) => void;
+ placeholder?: string;
+ "aria-label"?: string;
+}) {
+ const className =
+ "focus-ring w-full min-h-[52px] rounded-[3px] border border-line bg-ink px-3 py-2 text-[18px] leading-snug text-paper placeholder:text-ash-2";
+ if (onChange !== undefined) {
+ return (
+ <input
+ type="search"
+ value={value ?? ""}
+ onChange={(e) => onChange(e.target.value)}
+ placeholder={placeholder}
+ aria-label={ariaLabel}
+ className={className}
+ />
+ );
+ }
+ return (
+ <input
+ type="search"
+ name={name}
+ defaultValue={defaultValue ?? ""}
+ placeholder={placeholder}
+ aria-label={ariaLabel}
+ className={className}
+ />
+ );
+}
+
+/** Standard filter select (≥44px tall). */
+export function FilterSelect({
+ name,
+ value,
+ defaultValue,
+ onChange,
+ "aria-label": ariaLabel,
+ options,
+}: {
+ name?: string;
+ value?: string;
+ defaultValue?: string;
+ onChange?: (v: string) => void;
+ "aria-label"?: string;
+ options: Array<{ value: string; label: string }>;
+}) {
+ const className =
+ "focus-ring w-full min-h-[44px] rounded-[3px] border border-line bg-ink px-3 py-2 font-mono text-[13px] text-paper";
+ if (onChange !== undefined) {
+ return (
+ <select
+ value={value ?? ""}
+ onChange={(e) => onChange(e.target.value)}
+ aria-label={ariaLabel}
+ className={className}
+ >
+ {options.map((o) => (
+ <option key={o.value} value={o.value} className="bg-surface text-paper">
+ {o.label}
+ </option>
+ ))}
+ </select>
+ );
+ }
+ return (
+ <select
+ name={name}
+ defaultValue={defaultValue ?? ""}
+ aria-label={ariaLabel}
+ className={className}
+ >
+ {options.map((o) => (
+ <option key={o.value} value={o.value} className="bg-surface text-paper">
+ {o.label}
+ </option>
+ ))}
+ </select>
+ );
+}
+
+/** "Filter" submit button for server-side forms. */
+export function FilterSubmit({ label = "Filter" }: { label?: string }) {
+ return (
+ <div className="flex items-end">
+ <button
+ type="submit"
+ className="focus-ring label inline-flex min-h-[44px] w-full items-center justify-center rounded-[3px] border border-line bg-surface px-4 text-paper hover:bg-graphite"
+ >
+ {label}
+ </button>
+ </div>
+ );
+}
lib/feed.ts +73 -0
@@ -605,6 +605,33 @@ export function getAwards(): Award[] {
);
}
+/** Awards filtered by keyword text (agency, recipient, or program). Client-safe size: already small. */
+export function getAwardsFiltered(q?: string): Award[] {
+ if (!q || !q.trim()) return getAwards();
+ const like = `%${q.trim().replace(/[%_\\]/g, (m) => `\\${m}`)}%`;
+ return safeAll<Award>(
+ `SELECT id, agency, recipient, amount, action_date, program, source_url
+ FROM awards
+ WHERE (agency LIKE :q ESCAPE '\\' OR recipient LIKE :q ESCAPE '\\' OR program LIKE :q ESCAPE '\\')
+ ORDER BY amount DESC`,
+ { q: like },
+ (r) => {
+ const amount = num(r.amount);
+ return {
+ id: `award:${r.id}`,
+ agency: str(r.agency),
+ recipient: str(r.recipient),
+ amount,
+ amountText: formatUsd(amount),
+ actionDate: str(r.action_date),
+ program: str(r.program),
+ sourceUrl: ensureSource(r.source_url),
+ href: "/spending",
+ };
+ }
+ );
+}
+
export function getFiscal(): FiscalMetric[] {
return safeAll<FiscalMetric>(
`SELECT rowid, metric, date, value, source_url FROM fiscal ORDER BY date DESC`,
@@ -661,6 +688,39 @@ export function getVoteCount(chamber?: string): number {
return safeCount("votes");
}
+/** Filter shape for votes list - pushed into SQL for performance (35k+ rows). */
+export type VoteFilter = {
+ chamber?: string; // "senate" | "house"
+ q?: string; // keyword search on result + question text
+};
+
+/** Build WHERE clause + params for vote filters. */
+function buildVotesWhere(filter: VoteFilter): { where: string; params: Record<string, unknown> } {
+ const clauses: string[] = [];
+ const params: Record<string, unknown> = {};
+ if (filter.chamber) {
+ clauses.push("LOWER(chamber) = :ch");
+ params.ch = filter.chamber.toLowerCase();
+ }
+ if (filter.q) {
+ const like = `%${filter.q.trim().replace(/[%_\\]/g, (m) => `\\${m}`)}%`;
+ clauses.push("(result LIKE :q ESCAPE '\\' OR question LIKE :q ESCAPE '\\')");
+ params.q = like;
+ }
+ const where = clauses.length ? " WHERE " + clauses.join(" AND ") : "";
+ return { where, params };
+}
+
+/** Count votes matching the given filter - cheap SELECT COUNT(*). */
+export function getVoteCountFiltered(filter: VoteFilter = {}): number {
+ const { where, params } = buildVotesWhere(filter);
+ const row = safeGet<{ c: number }>(
+ `SELECT COUNT(*) AS c FROM votes${where}`,
+ params
+ );
+ return row?.c ?? 0;
+}
+
/** Paginated votes list - only fetches what the page renders. */
export function getVotes(limit = 100, offset = 0, chamber?: string): Vote[] {
if (chamber) {
@@ -684,6 +744,19 @@ export function getVotes(limit = 100, offset = 0, chamber?: string): Vote[] {
);
}
+/** Filtered, paginated votes list - pushes filtering into SQL (neutrality: date-ordered). */
+export function getVotesFiltered(filter: VoteFilter = {}, limit = 100, offset = 0): Vote[] {
+ const { where, params } = buildVotesWhere(filter);
+ return safeAll<Vote>(
+ `SELECT id, bill_id, chamber, date, yea, nay, present, result, source_url
+ FROM votes${where}
+ ORDER BY date DESC
+ LIMIT :limit OFFSET :offset`,
+ { ...params, limit, offset },
+ (r) => toVote(r)
+ );
+}
+
/**
* Single vote with its full roster of member positions. For voice / unanimous-
* consent votes (recorded = false, yea+nay = 0) there is no individual record;