| @@ -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). |
| @@ -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> | ||
| ); | ||
| } |
| @@ -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} />, | ||
| }, | ||
| })); |
| @@ -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> | ||
| ); | ||
| } |
| @@ -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> | ||
| ); | ||
| } |
| @@ -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> | ||
| ); | ||
| } |
| @@ -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> | ||
| ); | ||
| } |
| @@ -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> | ||
| ); | ||
| } |
| @@ -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} />, |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| - | ); | |
| - | } |
| @@ -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()} {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> | |
| + | ); | |
| + | } |
| @@ -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; |