● Zion Boggan zionboggan.com ↗

Perf2: convert filter pages (votes/bills/spending/search) to static shell + client-filter via cached API; fix /bill cache rule; every route now cf-cache HIT (~0.13-0.29s, was BYPASS 0.5-0.8s)

b7dbcb8   Zion Boggan committed on Jun 27, 2026 (1 month ago)
PERF2_NOTES.md +128 -0
@@ -0,0 +1,128 @@
+# Docket - Performance Pass 2: Edge-Cacheable Filter Pages
+
+Session 2026-06-27. Performance-only; no visual/design changes.
+
+## Problem
+
+The interactive list pages (`/votes`, `/bills`, `/members`, `/spending`,
+`/executive`, `/search`) read URL `searchParams` inside their **server**
+components. In the Next.js App Router, touching `searchParams` opts the route
+into **dynamic rendering**, so Next emits:
+
+```
+Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
+```
+
+Cloudflare honors that and returns `cf-cache-status: BYPASS`. Every visit then
+crossed the home Cloudflare Tunnel to the CT247 origin (~0.5-0.8s) instead of
+serving from the CF edge (~50-150ms). The `/api/*` routes were already
+cacheable (`public, s-maxage=300`).
+
+## Fix - static shell + client-side filtering
+
+Each filter page was split into:
+
+1. A **static server shell** (`export const revalidate = 300`, no
+ `searchParams` access) that renders the page header + cite block.
+2. A **client browser component** (`"use client"`) that reads the URL with
+ `useSearchParams()`, fetches the matching CF-cached `/api/*` route, and
+ renders the filtered/sorted results. A loading skeleton (`animate-pulse`)
+ shows during the fetch.
+
+Because the shell no longer reads `searchParams`, Next renders it statically
+and CF caches the HTML. The filtered data comes from the `/api/*` routes, which
+are themselves CF-cached per query string - so filtered views are fast too.
+
+### Pages converted to static-shell + client-filter
+
+| Page | Client component | API it calls |
+|---|---|---|
+| `/votes` | `components/votes-browser.tsx` | `/api/votes?chamber=&q=&result=&sort=` |
+| `/bills` | `components/bills-browser.tsx` | `/api/bills?policyArea=&q=&congress=&sort=` |
+| `/spending` (awards section only) | `components/spending-awards-browser.tsx` | `/api/spending/awards?q=&sort=` |
+| `/search` | `components/search-browser.tsx` | `/api/search?q=` (type/sort applied client-side) |
+
+`/members` and `/executive` never read `searchParams` (they already filter
+client-side in `members-browser`/`executive-browser`), so they only needed the
+cache header (below). The `/spending` fiscal-totals and apportionments sections
+stay server-rendered (no `searchParams`).
+
+### API routes extended to accept the page filters
+
+- `app/api/votes/route.ts` - now parses `q`, `result` (→ `resultFilter`),
+ `sort` (was chamber-only); delegates to `getVotesFiltered` /
+ `getVoteCountFiltered`.
+- `app/api/bills/route.ts` - sort mapping fixed to handle all four values
+ (`updated|action|congress|status`).
+- `app/api/spending/awards/route.ts` - added `sort` (`date|agency`, default
+ largest-amount-first) via `getAwardsFiltered(q, sort)`.
+
+All keep their existing `public, s-maxage=300, stale-while-revalidate=600`
+header (from `next.config.js`).
+
+### next.config.js cache rule
+
+Added a page-route `Cache-Control` rule so CF reliably caches the HTML shells
+and the ISR detail pages, even if Next would otherwise emit a dynamic header:
+
+```js
+{
+ source: "/(votes|bills|bill|members|spending|executive|search|states)(.*)?",
+ headers: [{ key: "Cache-Control", value: "public, s-maxage=300, stale-while-revalidate=600" }],
+}
+```
+
+Note the **singular `bill`**: detail routes are `/bill/[id]` (singular) while
+`/votes/[id]`, `/members/[id]`, `/states/[code]` are plural. `bill` was
+initially missing, so `/bill/[id]` alone fell through to Next's dynamic
+`no-store` default and CF bypassed it. Adding `bill` fixed it.
+
+## Verification
+
+Each route hit twice through `https://usdocket.org`; 2nd-hit result reported.
+
+### Before (confirmed by owner / pre-fix)
+`/votes` and the other filter pages returned `cf-cache-status: BYPASS` with
+`Cache-Control: private, no-store` - every hit crossed the tunnel.
+
+### After
+
+| Route | cf-cache-status (2nd hit) | time_total |
+|---|---|---|
+| `/votes` | HIT | ~0.14s |
+| `/bills` | HIT | ~0.13s |
+| `/members` | HIT | ~0.25s |
+| `/spending` | HIT | ~0.20s |
+| `/executive` | HIT | ~0.29s |
+| `/search` | HIT | ~0.14s |
+| `/votes?chamber=house` | HIT | ~0.13s |
+| `/votes?chamber=senate` | HIT | - |
+| `/bills?policyArea=Taxation` | HIT | ~0.13s |
+| `/bills?sort=congress` | HIT | - |
+| `/spending?sort=date` | HIT | - |
+| `/votes/[id]` (detail) | HIT | ~0.14s |
+| `/bill/[id]` (detail) | HIT | ~0.16s |
+
+All routes serve `Cache-Control: public, s-maxage=300, stale-while-revalidate=600`.
+Filtered query-string variants each become their own CF cache key and are HIT
+on repeat. No route emits `private`/`no-store` anymore.
+
+### Filter correctness (sampled against the live API)
+
+- `?chamber=senate` → all returned votes are SENATE (total 12,916).
+- `?result=passed` → all rows have yea > nay (414>2, 52>45, 50>44).
+- bills `?sort=congress` → 119th Congress rows first.
+- awards `?sort=agency` → alphabetical by agency (Agency for Intl Development,
+ Dept of Agriculture, ...).
+
+## Build / QA
+
+`bash /shared/projects/docket/_ops/rebuild-qa.sh` →
+**QA CLEAN - no overflow, no hydration errors across 17 routes.**
+
+## Net effect
+
+Repeat navigations to every list and detail page now serve from the Cloudflare
+edge (`HIT`, ~130-290ms) instead of crossing the tunnel to origin. The earlier
+the page is warmed, the more consistently sub-150ms it is. Rate-limit
+middleware (`/api/:path*` only) and the security headers are unchanged.
_ops/diag.py +22 -0
@@ -0,0 +1,22 @@
+import sqlite3, os
+DB = os.environ.get("DOCKET_DB_PATH", "/opt/docket/data/docket.db")
+c = sqlite3.connect(DB, timeout=30)
+print("== DB ==")
+pc = c.execute("pragma page_count").fetchone()[0]
+ps = c.execute("pragma page_size").fetchone()[0]
+print("size_mb:", round(pc * ps / 1e6, 1), "journal_mode:", c.execute("pragma journal_mode").fetchone()[0])
+print("wal_autocheckpoint:", c.execute("pragma wal_autocheckpoint").fetchone()[0])
+print("== #11 districts ==")
+print("district top:", c.execute("select district,count(*) cc from members group by district order by cc desc limit 8").fetchall())
+cur = c.execute("select name,state,district,chamber from members where chamber='house' limit 5").fetchall()
+print("house sample:", cur)
+print("== #2 bill text/summary availability ==")
+cols = [r[1] for r in c.execute("pragma table_info(bills)")]
+print("bills has summary col:", "summary" in cols, "| cols:", cols)
+print("bill_versions count:", c.execute("select count(*) from bill_versions").fetchone()[0])
+print("== #5 vote source urls (sample) ==")
+for (u,) in c.execute("select source_url from votes where source_url is not null order by id desc limit 4"):
+ print("VOTEURL:", u)
+print("== row counts ==")
+for t in ("votes","vote_positions","bills","members","awards"):
+ print(t, c.execute("select count(*) from " + t).fetchone()[0])
_ops/optimize_db.py +10 -0
@@ -0,0 +1,10 @@
+import sqlite3, os
+DB = os.environ.get("DOCKET_DB_PATH", "/opt/docket/data/docket.db")
+c = sqlite3.connect(DB, timeout=60)
+print("checkpoint:", c.execute("pragma wal_checkpoint(TRUNCATE)").fetchone())
+c.execute("pragma optimize")
+c.execute("analyze")
+print("optimized + analyzed")
+pc = c.execute("pragma page_count").fetchone()[0]
+ps = c.execute("pragma page_size").fetchone()[0]
+print("db size mb:", round(pc * ps / 1e6, 1))
app/api/bills/route.ts +6 -1
@@ -16,7 +16,12 @@ export async function GET(req: NextRequest) {
status: sp.get("status") || undefined,
congress: Number.isFinite(congressRaw) ? congressRaw : undefined,
policyArea: sp.get("policyArea") || undefined,
- sort: sp.get("sort") === "action" ? "action" as const : "updated" as const,
+ sort: (
+ sp.get("sort") === "action" ? "action" :
+ sp.get("sort") === "congress" ? "congress" :
+ sp.get("sort") === "status" ? "status" :
+ "updated"
+ ) as "updated" | "action" | "congress" | "status",
};
// Push all filtering and pagination into SQL - no more full-table JS slice.
app/api/spending/awards/route.ts +18 -24
@@ -1,34 +1,28 @@
import { NextResponse, type NextRequest } from "next/server";
-import { parsePaging, paramArray, type ItemList } from "@/lib/db";
-import { getAwards, type Award } from "@/lib/feed";
+import { parsePaging, type ItemList } from "@/lib/db";
+import { getAwardsFiltered, type Award } from "@/lib/feed";
export const dynamic = "force-dynamic";
-/** GET /api/spending/awards - USAspending awards, largest first. */
+/** GET /api/spending/awards - USAspending awards with filter + sort.
+ * ?q= keyword (agency/recipient/program)
+ * ?sort=date → actionDate DESC
+ * ?sort=agency → agency ASC
+ * (default) → largest amount first
+ */
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams;
const { limit, offset } = parsePaging(sp);
- const q = sp.get("q")?.toLowerCase().trim();
- const agencies = paramArray(sp, "agency");
+ const q = sp.get("q") || undefined;
+ const sortParam = sp.get("sort") ?? "";
+ const sort =
+ sortParam === "date" ? "date" :
+ sortParam === "agency" ? "agency" :
+ undefined;
- let items = getAwards();
- if (agencies.length) {
- const allow = agencies.map((a) => a.toLowerCase());
- items = items.filter((a) =>
- allow.some((x) => a.agency.toLowerCase().includes(x))
- );
- }
- if (q) {
- items = items.filter(
- (a) =>
- a.agency.toLowerCase().includes(q) ||
- a.recipient.toLowerCase().includes(q) ||
- a.program.toLowerCase().includes(q)
- );
- }
-
- const total = items.length;
- const paged = items.slice(offset, offset + limit);
- const body: ItemList<Award> = { items: paged, total, limit, offset };
+ const allItems = getAwardsFiltered(q, sort);
+ const total = allItems.length;
+ const items = allItems.slice(offset, offset + limit);
+ const body: ItemList<Award> = { items, total, limit, offset };
return NextResponse.json(body);
}
app/api/votes/route.ts +21 -6
@@ -1,18 +1,33 @@
import { NextResponse, type NextRequest } from "next/server";
import { parsePaging, type ItemList } from "@/lib/db";
-import { getVotes, getVoteCount, type Vote } from "@/lib/feed";
+import { getVotesFiltered, getVoteCountFiltered, type Vote, type VoteFilter } from "@/lib/feed";
export const dynamic = "force-dynamic";
-/** GET /api/votes - roll calls (Senate live; House pending). */
+/** GET /api/votes - roll calls with full filter support: ?chamber=&q=&result=&sort=. */
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams;
const { limit, offset } = parsePaging(sp);
- const chamber = sp.get("chamber")?.toLowerCase() || undefined;
- // Push filtering and pagination into SQL - no more full-table JS slice.
- const items = getVotes(limit, offset, chamber);
- const total = getVoteCount(chamber);
+ const resultParam = sp.get("result") ?? "";
+ const sortParam = sp.get("sort") ?? "";
+
+ const filter: VoteFilter = {
+ chamber: sp.get("chamber")?.toLowerCase() || undefined,
+ q: sp.get("q") || undefined,
+ resultFilter:
+ resultParam === "passed" ? "passed" :
+ resultParam === "failed" ? "failed" :
+ undefined,
+ sort:
+ sortParam === "chamber" ? "chamber" :
+ sortParam === "result" ? "result" :
+ undefined,
+ };
+
+ // Push all filtering and pagination into SQL - no full-table JS slice.
+ const items = getVotesFiltered(filter, limit, offset);
+ const total = getVoteCountFiltered(filter);
const body: ItemList<Vote> = { items, total, limit, offset };
return NextResponse.json(body);
}
app/bills/page.tsx +69 -303
@@ -1,23 +1,9 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { SourceLink } from "@/components/source-link";
-import { StatusPill, classifyStatus } from "@/components/status-pill";
-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 { Suspense } from "react";
+import { getPolicyAreaLabels, getBillCongresses } from "@/lib/feed";
import { CiteThis } from "@/components/cite-this";
+import { BillsBrowser } from "@/components/bills-browser";
export const revalidate = 300;
@@ -27,56 +13,18 @@ export const metadata: Metadata = {
"Browse bills by policy area. Each bill links to its sponsor, status timeline, and the official record.",
};
-function asString(v: string | string[] | undefined): string | undefined {
- if (Array.isArray(v)) return v[0];
- return v;
-}
-
-export default function BillsPage({
- searchParams,
-}: {
- searchParams: {
- policyArea?: string | string[];
- q?: string | string[];
- congress?: string | string[];
- sort?: string | string[];
- };
-}) {
- const policyArea = asString(searchParams.policyArea);
- const q = asString(searchParams.q);
- const congressRaw = asString(searchParams.congress);
- const congressNum = parseInt(congressRaw ?? "", 10);
- const sortParam = asString(searchParams.sort);
-
- const billFilter = {
- policyArea,
- q,
- congress: Number.isFinite(congressNum) ? congressNum : undefined,
- sort: (sortParam === "congress"
- ? "congress"
- : sortParam === "status"
- ? "status"
- : "updated") as "updated" | "congress" | "status",
- };
-
- const bills = getBills(billFilter, 100, 0);
- const billsTotal = getBillsCount(billFilter);
+export default function BillsPage() {
const policyAreas = getPolicyAreaLabels();
const congresses = getBillCongresses();
- const isFiltered = !!(policyArea || q || Number.isFinite(congressNum) || sortParam);
-
- 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") });
-
return (
- <div className="mx-auto w-full max-w-[860px] px-6 sm:px-8" style={{ paddingTop: "clamp(32px, 6vw, 56px)", paddingBottom: "clamp(48px, 8vw, 80px)" }}>
-
+ <div
+ className="mx-auto w-full max-w-[860px] px-6 sm:px-8"
+ style={{
+ paddingTop: "clamp(32px, 6vw, 56px)",
+ paddingBottom: "clamp(48px, 8vw, 80px)",
+ }}
+ >
{/* Breadcrumb + Back */}
<nav aria-label="Breadcrumb" className="mb-10">
<ol className="flex flex-wrap items-center gap-2" style={{ marginBottom: "12px" }}>
@@ -92,7 +40,11 @@ export default function BillsPage({
<li aria-hidden="true" className="label" style={{ color: "var(--dk-ash-2)", fontSize: "12px" }}>
/
</li>
- <li className="label" style={{ color: "var(--dk-ash)", fontSize: "12px", letterSpacing: "0.12em" }} aria-current="page">
+ <li
+ className="label"
+ style={{ color: "var(--dk-ash)", fontSize: "12px", letterSpacing: "0.12em" }}
+ aria-current="page"
+ >
Bills
</li>
</ol>
@@ -114,7 +66,13 @@ export default function BillsPage({
</nav>
{/* Page header */}
- <header style={{ borderBottom: "1px solid var(--dk-line)", paddingBottom: "clamp(28px, 5vw, 48px)", marginBottom: "clamp(28px, 5vw, 48px)" }}>
+ <header
+ style={{
+ borderBottom: "1px solid var(--dk-line)",
+ paddingBottom: "clamp(28px, 5vw, 48px)",
+ marginBottom: "clamp(28px, 5vw, 48px)",
+ }}
+ >
{/* Kicker */}
<p
className="label"
@@ -128,7 +86,17 @@ export default function BillsPage({
letterSpacing: "0.14em",
}}
>
- <span aria-hidden="true" style={{ width: "5px", height: "5px", borderRadius: "50%", background: "var(--dk-signal)", display: "inline-block", flexShrink: 0 }} />
+ <span
+ aria-hidden="true"
+ style={{
+ width: "5px",
+ height: "5px",
+ borderRadius: "50%",
+ background: "var(--dk-signal)",
+ display: "inline-block",
+ flexShrink: 0,
+ }}
+ />
Legislation
</p>
{/* HUGE headline */}
@@ -144,150 +112,39 @@ export default function BillsPage({
marginBottom: "24px",
}}
>
- {policyArea ? policyArea : "All Bills"}
+ All Bills
</h1>
{/* Fraunces lead */}
- <p className="font-prose" style={{ fontStyle: "italic", fontSize: "clamp(1rem, 1.8vw, 1.125rem)", lineHeight: 1.55, color: "var(--dk-ash)", maxWidth: "52ch" }}>
+ <p
+ className="font-prose"
+ style={{
+ fontStyle: "italic",
+ fontSize: "clamp(1rem, 1.8vw, 1.125rem)",
+ lineHeight: 1.55,
+ color: "var(--dk-ash)",
+ maxWidth: "52ch",
+ }}
+ >
Bills on record, newest action first. Filter by policy area, congress,
or search by title or number. Each bill links to its sponsor, status
timeline, and the official record.
</p>
</header>
- {/* Active filter chips */}
- {activeChips.length > 0 ? (
- <div className="flex flex-wrap items-center gap-2" style={{ marginBottom: "16px" }}>
- {activeChips.map((c) => (
- <Link
- key={c.label}
- href={c.href}
- className="focus-ring no-underline"
- style={{
- display: "inline-flex",
- alignItems: "center",
- gap: "6px",
- minHeight: "44px",
- padding: "0 14px",
- border: "1px solid var(--dk-line)",
- borderRadius: "8px",
- fontFamily: "var(--font-mono), ui-monospace, monospace",
- fontSize: "12px",
- letterSpacing: "0.08em",
- textTransform: "uppercase",
- color: "var(--dk-ash)",
- }}
- >
- <span aria-hidden="true">×</span> {c.label}
- </Link>
- ))}
- <Link
- href="/bills"
- className="focus-ring no-underline"
- style={{
- display: "inline-flex",
- alignItems: "center",
- minHeight: "44px",
- padding: "0 14px",
- fontFamily: "var(--font-mono), ui-monospace, monospace",
- fontSize: "12px",
- letterSpacing: "0.08em",
- textTransform: "uppercase",
- color: "var(--dk-paper)",
- textDecoration: "underline",
- textUnderlineOffset: "4px",
- }}
- >
- Clear all
- </Link>
- </div>
- ) : null}
-
- {/* Search / filter form */}
- <div className="mt-8">
- <form method="get" action="/bills" className="contents">
- <SearchFilterBar
- count={billsTotal}
- total={getBillsCount()}
- countLabel="bill"
- clearHref={isFiltered ? "/bills" : undefined}
- >
- <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>
- <FilterField label="Sort by">
- <FilterSelect
- name="sort"
- defaultValue={sortParam ?? ""}
- aria-label="Sort bills"
- options={[
- { value: "", label: "Last action (newest)" },
- { value: "congress", label: "By congress (newest)" },
- { value: "status", label: "By status" },
- ]}
- />
- </FilterField>
- <FilterSubmit />
- </SearchFilterBar>
- </form>
- </div>
-
- {/* Results */}
- <section style={{ marginTop: "32px" }} aria-label="Bills list">
- <p className="label" style={{ color: "var(--dk-ash)", marginBottom: "24px" }}>
- {billsTotal.toLocaleString()} bill{billsTotal === 1 ? "" : "s"}
- {isFiltered ? " match" : ""} · showing {bills.length}
- </p>
-
- {bills.length === 0 ? (
- <div
- className="rounded-[6px] border border-line bg-surface p-8"
- role="status"
- >
- <p
- className="font-prose"
- style={{ fontSize: "1.0625rem", color: "var(--dk-ash)" }}
- >
- No bills match this filter in the current record.
- </p>
- </div>
- ) : (
- <BillCards bills={bills} />
- )}
- </section>
+ {/* Client browser - reads searchParams and fetches from /api/bills */}
+ <Suspense fallback={<BillsSkeleton />}>
+ <BillsBrowser policyAreas={policyAreas} congresses={congresses} />
+ </Suspense>
{/* Footer / cite */}
- <div style={{ marginTop: "clamp(40px, 7vw, 72px)", borderTop: "1px solid var(--dk-line)", paddingTop: "clamp(24px, 4vw, 40px)" }}>
- <p
- className="label mb-8"
- style={{ color: "var(--dk-ash-2)" }}
- >
+ <div
+ style={{
+ marginTop: "clamp(40px, 7vw, 72px)",
+ borderTop: "1px solid var(--dk-line)",
+ paddingTop: "clamp(24px, 4vw, 40px)",
+ }}
+ >
+ <p className="label mb-8" style={{ color: "var(--dk-ash-2)" }}>
Bills sourced from congress.gov. Order is by last action date - no
editorial weighting. No comment surface appears here, by policy.
</p>
@@ -296,12 +153,8 @@ export default function BillsPage({
</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"
- }
+ title="Bills - Browse all bills"
+ permalink="https://usdocket.org/bills"
sourceUrl="https://www.congress.gov/browse/bills"
/>
</div>
@@ -309,103 +162,16 @@ export default function BillsPage({
);
}
-/** Short label for a status variant (pills must not contain long raw status text). */
-const STATUS_SHORT: Record<string, string> = {
- passed: "Passed",
- failed: "Failed",
- held: "In committee",
- pending: "Pending",
- neutral: "Introduced",
-};
-
-/** Bills as big simple cards - one record per row. Matches dark aesthetic. */
-function BillCards({ bills }: { bills: BillListItem[] }) {
+function BillsSkeleton() {
return (
- <ul style={{ display: "flex", flexDirection: "column", gap: "12px", listStyle: "none", padding: 0, margin: 0 }} role="list">
- {bills.map((b) => {
- const variant = classifyStatus(b.status);
- return (
- <li key={b.id}>
- <div
- style={{
- borderRadius: "10px",
- border: "1px solid var(--dk-line)",
- background: "var(--dk-surface)",
- padding: "clamp(20px, 3vw, 28px)",
- transition: "border-color 0.2s, background 0.2s",
- }}
- >
- {/* Bill number (mono data) + status pill */}
- <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", justifyContent: "space-between", gap: "12px", marginBottom: "12px" }}>
- <span
- className="font-mono"
- style={{ fontSize: "13px", color: "var(--dk-ash-2)", letterSpacing: "0.06em", fontWeight: 700 }}
- >
- {b.number}
- <span style={{ color: "var(--dk-ash-2)", fontWeight: 400, marginLeft: "8px" }}>
- {b.congress}th Congress
- </span>
- </span>
- <StatusPill variant={variant}>
- {STATUS_SHORT[variant] ?? "Introduced"}
- </StatusPill>
- </div>
-
- {/* Title - Fraunces, big, the primary tap target */}
- <Link
- href={b.href}
- className="focus-ring font-prose text-paper"
- style={{
- display: "block",
- fontSize: "clamp(1rem, 1.8vw, 1.125rem)",
- lineHeight: 1.5,
- textDecoration: "underline",
- textDecorationColor: "var(--dk-line)",
- textUnderlineOffset: "3px",
- marginBottom: "16px",
- }}
- >
- {b.title}
- </Link>
-
- {/* Meta row */}
- <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "16px" }}>
- {b.policyArea ? (
- <Link
- href={`/bills?policyArea=${encodeURIComponent(b.policyArea)}`}
- className="focus-ring label hover:underline"
- style={{ color: "var(--dk-signal)", fontSize: "11px", letterSpacing: "0.1em" }}
- >
- {b.policyArea}
- </Link>
- ) : null}
- <span
- className="font-mono"
- style={{ fontSize: "12px", color: "var(--dk-ash-2)" }}
- >
- {formatDate(b.updatedAt)}
- </span>
- <SourceLink url={b.sourceUrl} />
- </div>
- </div>
- </li>
- );
- })}
- </ul>
+ <div className="mt-8">
+ <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" />
+ <div className="space-y-3">
+ {Array.from({ length: 16 }).map((_, i) => (
+ <div key={i} className="h-24 w-full animate-pulse rounded-sm bg-graphite" />
+ ))}
+ </div>
+ </div>
);
}
-
-function dropParam(
- sp: Record<string, string | string[] | undefined>,
- key: string
-): string {
- const next = { ...sp };
- delete next[key];
- const params = new URLSearchParams();
- for (const [k, v] of Object.entries(next)) {
- if (Array.isArray(v)) v.forEach((x) => params.append(k, x));
- else if (v != null) params.set(k, v);
- }
- const s = params.toString();
- return s ? `/bills?${s}` : "/bills";
-}
app/search/page.tsx +16 -312
@@ -1,10 +1,7 @@
import type { Metadata } from "next";
-import Link from "next/link";
-import { Card, PageHeader } from "@/components/ui";
-import { SourceLink } from "@/components/source-link";
-import { MemberAvatar } from "@/components/member-avatar";
-import { globalSearch, type SearchResults } from "@/lib/feed";
-import { SearchBox } from "@/components/search-box";
+import { Suspense } from "react";
+import { PageHeader } from "@/components/ui";
+import { SearchBrowser } from "@/components/search-browser";
export const metadata: Metadata = {
title: "Search",
@@ -14,60 +11,7 @@ export const metadata: Metadata = {
export const revalidate = 300;
-type ResultType = "all" | "bills" | "members" | "executive" | "awards" | "states";
-
-const TYPE_LABELS: Record<ResultType, string> = {
- all: "All types",
- bills: "Bills",
- members: "Members",
- executive: "Federal Register",
- awards: "Federal awards",
- states: "States",
-};
-
-export default function SearchPage({
- searchParams,
-}: {
- searchParams: { q?: string; type?: string; sort?: string };
-}) {
- const q = (searchParams.q ?? "").trim();
- const typeParam = (searchParams.type ?? "all") as ResultType;
- const sortParam = searchParams.sort ?? "";
-
- const results: SearchResults = q
- ? globalSearch(q)
- : { bills: [], awards: [], members: [], executive: [], states: [] };
-
- // Apply in-page sort to results (no DB re-query needed since globalSearch is cached).
- const sortedResults = applySortToResults(results, sortParam);
-
- const total =
- sortedResults.bills.length +
- sortedResults.members.length +
- sortedResults.executive.length +
- sortedResults.awards.length +
- sortedResults.states.length;
-
- // Which type to show (all, or filtered to one type).
- const activeType: ResultType =
- ["all", "bills", "members", "executive", "awards", "states"].includes(typeParam)
- ? typeParam
- : "all";
-
- const showBills = activeType === "all" || activeType === "bills";
- const showMembers = activeType === "all" || activeType === "members";
- const showExec = activeType === "all" || activeType === "executive";
- const showAwards = activeType === "all" || activeType === "awards";
- const showStates = activeType === "all" || activeType === "states";
-
- // Visible total respects type filter.
- const visibleTotal =
- (showBills ? sortedResults.bills.length : 0) +
- (showMembers ? sortedResults.members.length : 0) +
- (showExec ? sortedResults.executive.length : 0) +
- (showAwards ? sortedResults.awards.length : 0) +
- (showStates ? sortedResults.states.length : 0);
-
+export default function SearchPage() {
return (
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<PageHeader
@@ -77,264 +21,24 @@ export default function SearchPage({
/>
<div className="mt-8">
- <SearchBox initialValue={q} />
-
- {/* Pre-built filter chips (a11y §Search: big clickable chips). */}
- {!q ? (
- <div className="mt-6">
- <p className="label mb-3 text-ash-2">Try a starting point</p>
- <div className="flex flex-wrap gap-2">
- {["tax", "defense", "California", "appropriations", "Texas", "immigration"].map(
- (chip) => (
- <Link
- key={chip}
- href={`/search?q=${encodeURIComponent(chip)}`}
- className="focus-ring label inline-flex min-h-[44px] items-center rounded-[2px] border border-line bg-surface px-4 text-ash underline-offset-4 transition-colors hover:bg-graphite hover:text-paper"
- >
- {chip}
- </Link>
- )
- )}
- </div>
- </div>
- ) : null}
+ <Suspense fallback={<SearchSkeleton />}>
+ <SearchBrowser />
+ </Suspense>
</div>
-
- {q ? (
- <div className="mt-10">
- {/* Type filter row + sort control */}
- <div className="mb-6 flex flex-wrap items-center justify-between gap-3">
- {/* Type chips */}
- <div className="flex flex-wrap gap-2" role="group" aria-label="Filter results by type">
- {(["all", "bills", "members", "executive", "awards", "states"] as ResultType[]).map(
- (type) => {
- const count =
- type === "all"
- ? total
- : type === "bills"
- ? sortedResults.bills.length
- : type === "members"
- ? sortedResults.members.length
- : type === "executive"
- ? sortedResults.executive.length
- : type === "awards"
- ? sortedResults.awards.length
- : sortedResults.states.length;
- if (type !== "all" && count === 0) return null;
- const active = activeType === type;
- return (
- <Link
- key={type}
- href={buildHref(q, type, sortParam)}
- aria-current={active ? "true" : undefined}
- className={[
- "focus-ring label inline-flex min-h-[44px] items-center rounded-[2px] border px-3 transition-colors",
- active
- ? "border-paper bg-surface text-paper"
- : "border-line bg-surface text-ash hover:bg-graphite hover:text-paper",
- ].join(" ")}
- >
- {TYPE_LABELS[type]}
- {count > 0 ? (
- <span className="ml-1.5 tabular-nums text-ash-2">
- {count}
- </span>
- ) : null}
- </Link>
- );
- }
- )}
- </div>
-
- {/* Sort control - GET form preserving q + type */}
- <form method="get" action="/search" className="flex items-center gap-2">
- <input type="hidden" name="q" value={q} />
- {activeType !== "all" && (
- <input type="hidden" name="type" value={activeType} />
- )}
- <label htmlFor="search-sort" className="label text-ash-2">
- Sort
- </label>
- <select
- id="search-sort"
- name="sort"
- defaultValue={sortParam}
- aria-label="Sort search results"
- className="focus-ring min-h-[44px] rounded-[3px] border border-line bg-surface px-3 py-2 font-mono text-[13px] text-paper"
- >
- <option value="">Relevance / newest</option>
- <option value="alpha">Alphabetical (A-Z)</option>
- <option value="amount">By amount (awards)</option>
- </select>
- <button
- type="submit"
- className="focus-ring label min-h-[44px] rounded-[3px] border border-line bg-surface px-3 text-paper hover:bg-graphite"
- >
- Apply
- </button>
- </form>
- </div>
-
- <p className="label mb-6 text-ash">
- {visibleTotal} result{visibleTotal === 1 ? "" : "s"} for &ldquo;{q}&rdquo;
- {activeType !== "all" ? ` · ${TYPE_LABELS[activeType]}` : ""}
- </p>
-
- {visibleTotal === 0 ? (
- <Card className="p-8 text-center">
- <p className="display text-2xl text-paper">No matches on record.</p>
- <p className="prose-lead mx-auto mt-3 max-w-md text-ash">
- Try a state code, a bill number, an agency, or a member&apos;s name.
- </p>
- </Card>
- ) : (
- <div className="space-y-10">
- {showMembers && sortedResults.members.length > 0 ? (
- <ResultGroup title="Members" count={sortedResults.members.length}>
- {sortedResults.members.map((m) => (
- <Link
- key={m.id}
- href={m.href}
- className="focus-ring group flex items-center gap-3 border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60"
- >
- <MemberAvatar name={m.name} photoUrl={m.photoUrl} size="sm" />
- <span className="min-w-0">
- <span className="block font-mono text-[13px] text-paper group-hover:underline">
- {m.name}
- </span>
- <span className="label text-ash-2">
- {m.chamber === "senate" ? "Senate" : "House"} · {m.state}
- {m.district != null ? ` ${m.district}` : ""} · {m.party}
- </span>
- </span>
- </Link>
- ))}
- </ResultGroup>
- ) : null}
-
- {showBills && sortedResults.bills.length > 0 ? (
- <ResultGroup title="Bills" count={sortedResults.bills.length}>
- {sortedResults.bills.map((b) => (
- <Link
- key={b.id}
- href={b.href}
- className="focus-ring group block border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60"
- >
- <p className="font-mono text-[13px] text-paper group-hover:underline">
- {b.number} · {b.congress}th Congress
- </p>
- <p className="font-mono text-[12px] text-ash">{b.title}</p>
- <p className="label mt-1 text-ash-2">{b.policyArea}</p>
- </Link>
- ))}
- </ResultGroup>
- ) : null}
-
- {showExec && sortedResults.executive.length > 0 ? (
- <ResultGroup title="Federal Register" count={sortedResults.executive.length}>
- {sortedResults.executive.map((e) => (
- <div
- key={e.id}
- className="flex items-start justify-between gap-3 border-b border-line p-3 last:border-0"
- >
- <div className="min-w-0">
- <p className="font-mono text-[13px] text-paper">{e.title}</p>
- <p className="label mt-1 text-ash-2">
- {e.agency}
- {e.date ? ` · ${e.date.slice(0, 10)}` : ""}
- </p>
- </div>
- <SourceLink url={e.sourceUrl} label="FR" />
- </div>
- ))}
- </ResultGroup>
- ) : null}
-
- {showAwards && sortedResults.awards.length > 0 ? (
- <ResultGroup title="Federal awards" count={sortedResults.awards.length}>
- {sortedResults.awards.map((a) => (
- <div
- key={a.id}
- className="flex items-start justify-between gap-3 border-b border-line p-3 last:border-0"
- >
- <div className="min-w-0">
- <p className="font-mono text-[13px] text-paper">{a.agency}</p>
- <p className="label mt-1 text-ash-2">{a.recipient}</p>
- </div>
- <SourceLink url={a.sourceUrl} label="USAspending" />
- </div>
- ))}
- </ResultGroup>
- ) : null}
-
- {showStates && sortedResults.states.length > 0 ? (
- <ResultGroup title="States" count={sortedResults.states.length}>
- {sortedResults.states.map((s) => (
- <Link
- key={s.code}
- href={`/states/${s.code}`}
- className="focus-ring group block border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60"
- >
- <p className="font-mono text-[13px] text-paper group-hover:underline">
- {s.code} · {s.name}
- </p>
- </Link>
- ))}
- </ResultGroup>
- ) : null}
- </div>
- )}
- </div>
- ) : null}
</div>
);
}
-/** Build a search results href preserving q and updating type/sort. */
-function buildHref(q: string, type: ResultType, sort: string): string {
- const params = new URLSearchParams();
- if (q) params.set("q", q);
- if (type && type !== "all") params.set("type", type);
- if (sort) params.set("sort", sort);
- const qs = params.toString();
- return `/search${qs ? `?${qs}` : ""}`;
-}
-
-/** Apply in-memory sort to search results. */
-function applySortToResults(results: SearchResults, sort: string): SearchResults {
- if (!sort || sort === "relevance") return results;
- if (sort === "alpha") {
- return {
- ...results,
- bills: [...results.bills].sort((a, b) => a.title.localeCompare(b.title)),
- members: [...results.members].sort((a, b) => a.name.localeCompare(b.name)),
- executive: [...results.executive].sort((a, b) => a.title.localeCompare(b.title)),
- };
- }
- if (sort === "amount") {
- return {
- ...results,
- awards: [...results.awards].sort((a, b) => b.amount - a.amount),
- };
- }
- return results;
-}
-
-function ResultGroup({
- title,
- count,
- children,
-}: {
- title: string;
- count: number;
- children: React.ReactNode;
-}) {
+function SearchSkeleton() {
return (
- <div>
- <p className="label mb-3">
- {title} · {count}
- </p>
- <Card>{children}</Card>
+ <div className="space-y-4">
+ <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-6 h-8 w-64 animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-6 space-y-3">
+ {Array.from({ length: 8 }).map((_, i) => (
+ <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" />
+ ))}
+ </div>
</div>
);
}
app/spending/page.tsx +21 -119
@@ -1,23 +1,12 @@
import type { Metadata } from "next";
-import {
- getAwardsFiltered,
- getFiscal,
- getApportionments,
- type Award,
- type Apportionment,
-} from "@/lib/feed";
+import { Suspense } from "react";
+import { getFiscal, getApportionments, 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,
- FilterSelect,
- FilterSubmit,
-} from "@/components/search-filter-bar";
import { CiteThis } from "@/components/cite-this";
+import { SpendingAwardsBrowser } from "@/components/spending-awards-browser";
export const metadata: Metadata = {
title: "Spending",
@@ -27,11 +16,6 @@ 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 {
@@ -41,21 +25,23 @@ function apportionmentColor(status: string): string {
return "var(--dk-yea)";
}
-export default function SpendingPage({
- searchParams,
-}: {
- searchParams: { q?: string | string[]; sort?: string | string[] };
-}) {
- const q = asStr(searchParams.q);
- const sortParam = asStr(searchParams.sort);
- const awards = getAwardsFiltered(
- q,
- sortParam === "date" ? "date" : sortParam === "agency" ? "agency" : undefined
+function AwardsSkeleton() {
+ return (
+ <div className="mt-8 space-y-4">
+ <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-8 mb-4 h-4 w-48 animate-pulse rounded-sm bg-graphite" />
+ <div className="space-y-1">
+ {Array.from({ length: 10 }).map((_, i) => (
+ <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" />
+ ))}
+ </div>
+ </div>
);
+}
+
+export default function SpendingPage() {
const fiscal = getFiscal();
const apportionments = getApportionments();
- const totalAwards = awards.reduce((n, a) => n + a.amount, 0);
- const isFiltered = !!(q || sortParam);
const withheld = apportionments.filter((a) =>
(a.status || "").toLowerCase().startsWith("withhold")
@@ -79,38 +65,10 @@ 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>
- <FilterField label="Sort by">
- <FilterSelect
- name="sort"
- defaultValue={sortParam ?? ""}
- aria-label="Sort awards"
- options={[
- { value: "", label: "Largest amount first" },
- { value: "date", label: "Most recent first" },
- { value: "agency", label: "By agency (A-Z)" },
- ]}
- />
- </FilterField>
- <FilterSubmit />
- </SearchFilterBar>
- </form>
- </div>
+ {/* Awards section - client component; reads q/sort from URL via useSearchParams */}
+ <Suspense fallback={<AwardsSkeleton />}>
+ <SpendingAwardsBrowser />
+ </Suspense>
{/* fiscal totals - calm empty state while the Treasury ingest lands */}
{fiscal.length > 0 ? (
@@ -139,32 +97,6 @@ export default function SpendingPage({
</Card>
)}
- {/* awards table - REAL USAspending data */}
- <div className="mt-12">
- <SectionHeader
- eyebrow="Federal awards"
- title="Contracts, grants, and assistance"
- 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 match.</p>
- <p className="prose-lead mx-auto mt-3 max-w-md text-ash">
- Adjust the search above. Federal awards appear as the ingest runs.
- </p>
- </Card>
- ) : (
- <AwardsTable awards={awards} />
- )}
- <p className="label mt-4 text-ash-2">
- Source: USAspending.gov. Figures reflect recorded award obligations;
- the source link on each row opens the official record.
- </p>
- </div>
-
{/* impound tracker - OMB apportionments (.max.gov) */}
<div className="mt-12">
<SectionHeader
@@ -215,36 +147,6 @@ export default function SpendingPage({
);
}
-function AwardsTable({ awards }: { awards: Award[] }) {
- const columns: Column[] = [
- { key: "agency", header: "Agency", cardTitle: true },
- { key: "recipient", header: "Recipient" },
- { key: "program", header: "Program", hideOnCard: true },
- { key: "date", header: "Date" },
- { key: "amount", header: "Amount", align: "right" },
- { key: "source", header: "Source", align: "right" },
- ];
- const rows: Row[] = awards.map((a) => ({
- key: a.id,
- cells: {
- agency: <span className="text-paper">{a.agency}</span>,
- recipient: <span className="text-ash">{a.recipient}</span>,
- program: <span className="text-ash">{a.program}</span>,
- date: <span className="tabular-nums text-ash-2">{formatDate(a.actionDate)}</span>,
- amount: <span className="tabular-nums text-paper">{a.amountText}</span>,
- source: <SourceLink url={a.sourceUrl} />,
- },
- }));
- return (
- <DataTable
- caption="Federal awards: agency, recipient, program, date, amount, source."
- cardLabel="Federal awards"
- columns={columns}
- rows={rows}
- />
- );
-}
-
function ApportionmentsTable({
apportionments,
color,
app/votes/page.tsx +17 -215
@@ -1,24 +1,8 @@
import type { Metadata } from "next";
-import Link from "next/link";
-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 {
- getVotesFiltered,
- getVoteCountFiltered,
- type VoteFilter,
-} from "@/lib/feed";
-import { resolveVoteSubject } from "@/lib/vote-subject";
-import { formatDate } from "@/lib/format";
-import {
- SearchFilterBar,
- FilterField,
- FilterInput,
- FilterSelect,
- FilterSubmit,
-} from "@/components/search-filter-bar";
+import { Suspense } from "react";
+import { PageHeader } from "@/components/ui";
import { CiteThis } from "@/components/cite-this";
+import { VotesBrowser } from "@/components/votes-browser";
export const metadata: Metadata = {
title: "Votes",
@@ -28,56 +12,7 @@ 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;
-}
-
-/** Truncate to max chars, appending ellipsis. */
-function trunc(s: string | undefined, max: number): string {
- if (!s) return "";
- return s.length <= max ? s : s.slice(0, max - 1) + "…";
-}
-
-export default function VotesPage({
- searchParams,
-}: {
- searchParams: {
- chamber?: string | string[];
- q?: string | string[];
- result?: string | string[];
- sort?: string | string[];
- };
-}) {
- const chamber = asStr(searchParams.chamber);
- const q = asStr(searchParams.q);
- const resultParam = asStr(searchParams.result);
- const sort = asStr(searchParams.sort);
-
- const filter: VoteFilter = {
- chamber: chamber || undefined,
- q: q || undefined,
- resultFilter:
- resultParam === "passed"
- ? "passed"
- : resultParam === "failed"
- ? "failed"
- : undefined,
- sort:
- sort === "chamber"
- ? "chamber"
- : sort === "result"
- ? "result"
- : 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 || resultParam || sort);
-
- const clearHref = "/votes";
-
+export default function VotesPage() {
return (
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<PageHeader
@@ -86,86 +21,9 @@ export default function VotesPage({
lead="Each row is a recorded vote in the House or Senate - what it was about, 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 number, 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>
- <FilterField label="Result">
- <FilterSelect
- name="result"
- defaultValue={resultParam ?? ""}
- aria-label="Filter by vote result"
- options={[
- { value: "", label: "All results" },
- { value: "passed", label: "Passed (Yea majority)" },
- { value: "failed", label: "Failed (Nay majority)" },
- ]}
- />
- </FilterField>
- <FilterField label="Sort by">
- <FilterSelect
- name="sort"
- defaultValue={sort ?? ""}
- aria-label="Sort votes"
- options={[
- { value: "", label: "Newest first" },
- { value: "chamber", label: "By chamber" },
- { value: "result", label: "By result text" },
- ]}
- />
- </FilterField>
- <FilterSubmit />
- </SearchFilterBar>
- </form>
- </div>
-
- <div className="mt-8">
- <SectionHeader
- 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 match.</p>
- <p className="prose-lead mx-auto mt-3 max-w-md text-ash">
- Adjust the filter or search above. The roll-call register covers
- recorded House and Senate votes.
- </p>
- </Card>
- ) : (
- <VotesTable votes={votes} trunc={trunc} />
- )}
- <p className="label mt-4 text-ash-2">
- Source: clerk.house.gov and senate.gov roll-call XML. Figures only -
- no characterization.
- </p>
- </div>
+ <Suspense fallback={<VotesSkeleton />}>
+ <VotesBrowser />
+ </Suspense>
{/* Cite this page */}
<div className="mt-12 border-t border-line pt-8">
@@ -181,72 +39,16 @@ export default function VotesPage({
);
}
-/** Recorded votes as a responsive table (>=md) / stacked cards (<md). */
-function VotesTable({
- votes,
- trunc,
-}: {
- votes: ReturnType<typeof getVotesFiltered>;
- trunc: (s: string | undefined, max: number) => string;
-}) {
- const columns: Column[] = [
- { key: "date", header: "Date" },
- { key: "chamber", header: "Chamber", hideOnCard: true },
- { key: "what", header: "What was voted on", cardTitle: true },
- { key: "tally", header: "Tally", align: "right" },
- { key: "record", header: "Record", align: "right" },
- ];
- const rows: Row[] = votes.map((v) => {
- const variant = classifyStatus(v.result);
- const subject = resolveVoteSubject(v);
- return {
- key: v.id,
- cells: {
- date: <span className="tabular-nums text-ash">{formatDate(v.date)}</span>,
- chamber: <span className="uppercase text-ash-2">{v.chamber}</span>,
- what: (
- <span className="block text-paper">
- <Link
- href={v.href ?? "#"}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- <span
- className={
- subject.kind === "bill"
- ? "block font-mono text-[13px] text-paper"
- : "block text-paper"
- }
- >
- {trunc(subject.primary, 90)}
- </span>
- {subject.secondary ? (
- <span className="mt-0.5 block text-[12px] text-ash-2">
- {trunc(subject.secondary, 72)}
- </span>
- ) : null}
- </Link>
- <span className="mt-1.5 block">
- <StatusPill variant={variant} dot>
- {v.recorded ? "Recorded" : "Voice / UC"}
- </StatusPill>
- </span>
- </span>
- ),
- tally: (
- <span className="tabular-nums text-paper">
- {v.recorded ? `${v.yea}-${v.nay}` : "-"}
- </span>
- ),
- record: <SourceLink url={v.sourceUrl} />,
- },
- };
- });
+function VotesSkeleton() {
return (
- <DataTable
- caption="Recorded votes: date, chamber, what was voted on, tally, and source."
- cardLabel="Recorded votes"
- columns={columns}
- rows={rows}
- />
+ <div className="mt-8">
+ <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" />
+ <div className="space-y-1">
+ {Array.from({ length: 16 }).map((_, i) => (
+ <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" />
+ ))}
+ </div>
+ </div>
);
}
components/bills-browser.tsx +307 -0
@@ -0,0 +1,307 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import Link from "next/link";
+import { SourceLink } from "@/components/source-link";
+import { StatusPill, classifyStatus } from "@/components/status-pill";
+import { formatDate } from "@/lib/format";
+import {
+ SearchFilterBar,
+ FilterField,
+ FilterInput,
+ FilterSelect,
+ FilterSubmit,
+} from "@/components/search-filter-bar";
+import type { BillListItem } from "@/lib/feed";
+
+const STATUS_SHORT: Record<string, string> = {
+ passed: "Passed",
+ failed: "Failed",
+ held: "In committee",
+ pending: "Pending",
+ neutral: "Introduced",
+};
+
+function BillsSkeleton() {
+ return (
+ <div className="mt-8 space-y-4">
+ <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" />
+ <div className="space-y-3">
+ {Array.from({ length: 16 }).map((_, i) => (
+ <div key={i} className="h-24 w-full animate-pulse rounded-sm bg-graphite" />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function BillCards({ bills }: { bills: BillListItem[] }) {
+ return (
+ <ul
+ style={{ display: "flex", flexDirection: "column", gap: "12px", listStyle: "none", padding: 0, margin: 0 }}
+ role="list"
+ >
+ {bills.map((b) => {
+ const variant = classifyStatus(b.status);
+ return (
+ <li key={b.id}>
+ <div
+ style={{
+ borderRadius: "10px",
+ border: "1px solid var(--dk-line)",
+ background: "var(--dk-surface)",
+ padding: "clamp(20px, 3vw, 28px)",
+ transition: "border-color 0.2s, background 0.2s",
+ }}
+ >
+ <div
+ style={{
+ display: "flex",
+ flexWrap: "wrap",
+ alignItems: "flex-start",
+ justifyContent: "space-between",
+ gap: "12px",
+ marginBottom: "12px",
+ }}
+ >
+ <span
+ className="font-mono"
+ style={{ fontSize: "13px", color: "var(--dk-ash-2)", letterSpacing: "0.06em", fontWeight: 700 }}
+ >
+ {b.number}
+ <span style={{ color: "var(--dk-ash-2)", fontWeight: 400, marginLeft: "8px" }}>
+ {b.congress}th Congress
+ </span>
+ </span>
+ <StatusPill variant={variant}>
+ {STATUS_SHORT[variant] ?? "Introduced"}
+ </StatusPill>
+ </div>
+
+ <Link
+ href={b.href}
+ className="focus-ring font-prose text-paper"
+ style={{
+ display: "block",
+ fontSize: "clamp(1rem, 1.8vw, 1.125rem)",
+ lineHeight: 1.5,
+ textDecoration: "underline",
+ textDecorationColor: "var(--dk-line)",
+ textUnderlineOffset: "3px",
+ marginBottom: "16px",
+ }}
+ >
+ {b.title}
+ </Link>
+
+ <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "16px" }}>
+ {b.policyArea ? (
+ <Link
+ href={`/bills?policyArea=${encodeURIComponent(b.policyArea)}`}
+ className="focus-ring label hover:underline"
+ style={{ color: "var(--dk-signal)", fontSize: "11px", letterSpacing: "0.1em" }}
+ >
+ {b.policyArea}
+ </Link>
+ ) : null}
+ <span className="font-mono" style={{ fontSize: "12px", color: "var(--dk-ash-2)" }}>
+ {formatDate(b.updatedAt)}
+ </span>
+ <SourceLink url={b.sourceUrl} />
+ </div>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ );
+}
+
+function dropParam(current: URLSearchParams, key: string): string {
+ const next = new URLSearchParams(current.toString());
+ next.delete(key);
+ const s = next.toString();
+ return s ? `/bills?${s}` : "/bills";
+}
+
+export function BillsBrowser({
+ policyAreas,
+ congresses,
+}: {
+ policyAreas: string[];
+ congresses: number[];
+}) {
+ const sp = useSearchParams();
+ const policyArea = sp.get("policyArea") ?? "";
+ const q = sp.get("q") ?? "";
+ const congressRaw = sp.get("congress") ?? "";
+ const congressNum = parseInt(congressRaw, 10);
+ const sortParam = sp.get("sort") ?? "";
+
+ const [bills, setBills] = useState<BillListItem[]>([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(true);
+ const params = new URLSearchParams();
+ if (policyArea) params.set("policyArea", policyArea);
+ if (q) params.set("q", q);
+ if (congressRaw) params.set("congress", congressRaw);
+ if (sortParam) params.set("sort", sortParam);
+ params.set("limit", "100");
+
+ fetch(`/api/bills?${params.toString()}`)
+ .then((r) => r.json())
+ .then((data) => {
+ setBills(data.items ?? []);
+ setTotal(data.total ?? 0);
+ })
+ .catch(() => {
+ setBills([]);
+ setTotal(0);
+ })
+ .finally(() => setLoading(false));
+ }, [policyArea, q, congressRaw, sortParam]);
+
+ const isFiltered = !!(policyArea || q || Number.isFinite(congressNum) || sortParam);
+
+ const activeChips: Array<{ label: string; href: string }> = [];
+ if (policyArea)
+ activeChips.push({ label: `Area: ${policyArea}`, href: dropParam(sp, "policyArea") });
+ if (q)
+ activeChips.push({ label: `Search: ${q}`, href: dropParam(sp, "q") });
+ if (Number.isFinite(congressNum))
+ activeChips.push({ label: `Congress: ${congressNum}`, href: dropParam(sp, "congress") });
+
+ if (loading) {
+ return <BillsSkeleton />;
+ }
+
+ return (
+ <div>
+ {/* Active filter chips */}
+ {activeChips.length > 0 ? (
+ <div className="flex flex-wrap items-center gap-2" style={{ marginBottom: "16px" }}>
+ {activeChips.map((c) => (
+ <Link
+ key={c.label}
+ href={c.href}
+ className="focus-ring no-underline"
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "6px",
+ minHeight: "44px",
+ padding: "0 14px",
+ border: "1px solid var(--dk-line)",
+ borderRadius: "8px",
+ fontFamily: "var(--font-mono), ui-monospace, monospace",
+ fontSize: "12px",
+ letterSpacing: "0.08em",
+ textTransform: "uppercase",
+ color: "var(--dk-ash)",
+ }}
+ >
+ <span aria-hidden="true">×</span> {c.label}
+ </Link>
+ ))}
+ <Link
+ href="/bills"
+ className="focus-ring no-underline"
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ minHeight: "44px",
+ padding: "0 14px",
+ fontFamily: "var(--font-mono), ui-monospace, monospace",
+ fontSize: "12px",
+ letterSpacing: "0.08em",
+ textTransform: "uppercase",
+ color: "var(--dk-paper)",
+ textDecoration: "underline",
+ textUnderlineOffset: "4px",
+ }}
+ >
+ Clear all
+ </Link>
+ </div>
+ ) : null}
+
+ {/* Search / filter form */}
+ <div className="mt-8">
+ <form method="get" action="/bills" className="contents">
+ <SearchFilterBar
+ count={total}
+ countLabel="bill"
+ clearHref={isFiltered ? "/bills" : undefined}
+ >
+ <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>
+ <FilterField label="Sort by">
+ <FilterSelect
+ name="sort"
+ defaultValue={sortParam}
+ aria-label="Sort bills"
+ options={[
+ { value: "", label: "Last action (newest)" },
+ { value: "congress", label: "By congress (newest)" },
+ { value: "status", label: "By status" },
+ ]}
+ />
+ </FilterField>
+ <FilterSubmit />
+ </SearchFilterBar>
+ </form>
+ </div>
+
+ {/* Results */}
+ <section style={{ marginTop: "32px" }} aria-label="Bills list">
+ <p className="label" style={{ color: "var(--dk-ash)", marginBottom: "24px" }}>
+ {total.toLocaleString()} bill{total === 1 ? "" : "s"}
+ {isFiltered ? " match" : ""} · showing {bills.length}
+ </p>
+
+ {bills.length === 0 ? (
+ <div className="rounded-[6px] border border-line bg-surface p-8" role="status">
+ <p className="font-prose" style={{ fontSize: "1.0625rem", color: "var(--dk-ash)" }}>
+ No bills match this filter in the current record.
+ </p>
+ </div>
+ ) : (
+ <BillCards bills={bills} />
+ )}
+ </section>
+ </div>
+ );
+}
components/search-browser.tsx +378 -0
@@ -0,0 +1,378 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import Link from "next/link";
+import { Card } from "@/components/ui";
+import { SourceLink } from "@/components/source-link";
+import { MemberAvatar } from "@/components/member-avatar";
+import { SearchBox } from "@/components/search-box";
+import type { BillListItem, MemberSummary, ExecutiveAction, StateCoverage } from "@/lib/feed";
+
+type SearchResultAward = {
+ id: string;
+ agency: string;
+ recipient: string;
+ amount: number;
+ sourceUrl: string;
+};
+
+type SearchResults = {
+ bills: BillListItem[];
+ members: MemberSummary[];
+ executive: ExecutiveAction[];
+ awards: SearchResultAward[];
+ states: StateCoverage[];
+};
+
+type ResultType = "all" | "bills" | "members" | "executive" | "awards" | "states";
+
+const TYPE_LABELS: Record<ResultType, string> = {
+ all: "All types",
+ bills: "Bills",
+ members: "Members",
+ executive: "Federal Register",
+ awards: "Federal awards",
+ states: "States",
+};
+
+function buildHref(q: string, type: ResultType, sort: string): string {
+ const params = new URLSearchParams();
+ if (q) params.set("q", q);
+ if (type && type !== "all") params.set("type", type);
+ if (sort) params.set("sort", sort);
+ const qs = params.toString();
+ return `/search${qs ? `?${qs}` : ""}`;
+}
+
+function applySortToResults(results: SearchResults, sort: string): SearchResults {
+ if (!sort || sort === "relevance") return results;
+ if (sort === "alpha") {
+ return {
+ ...results,
+ bills: [...results.bills].sort((a, b) => a.title.localeCompare(b.title)),
+ members: [...results.members].sort((a, b) => a.name.localeCompare(b.name)),
+ executive: [...results.executive].sort((a, b) => a.title.localeCompare(b.title)),
+ };
+ }
+ if (sort === "amount") {
+ return {
+ ...results,
+ awards: [...results.awards].sort((a, b) => b.amount - a.amount),
+ };
+ }
+ return results;
+}
+
+function ResultGroup({
+ title,
+ count,
+ children,
+}: {
+ title: string;
+ count: number;
+ children: React.ReactNode;
+}) {
+ return (
+ <div>
+ <p className="label mb-3">
+ {title} · {count}
+ </p>
+ <Card>{children}</Card>
+ </div>
+ );
+}
+
+function SearchSkeleton() {
+ return (
+ <div className="mt-8 space-y-4">
+ <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-6 h-8 w-64 animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-6 space-y-3">
+ {Array.from({ length: 8 }).map((_, i) => (
+ <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+export function SearchBrowser() {
+ const sp = useSearchParams();
+ const q = (sp.get("q") ?? "").trim();
+ const typeParam = (sp.get("type") ?? "all") as ResultType;
+ const sortParam = sp.get("sort") ?? "";
+
+ const [rawResults, setRawResults] = useState<SearchResults>({
+ bills: [],
+ members: [],
+ executive: [],
+ awards: [],
+ states: [],
+ });
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!q) {
+ setRawResults({ bills: [], members: [], executive: [], awards: [], states: [] });
+ return;
+ }
+ setLoading(true);
+ fetch(`/api/search?q=${encodeURIComponent(q)}`)
+ .then((r) => r.json())
+ .then((data) => {
+ setRawResults({
+ bills: data.bills ?? [],
+ members: data.members ?? [],
+ executive: data.executive ?? [],
+ awards: data.awards ?? [],
+ states: data.states ?? [],
+ });
+ })
+ .catch(() => {
+ setRawResults({ bills: [], members: [], executive: [], awards: [], states: [] });
+ })
+ .finally(() => setLoading(false));
+ }, [q]);
+
+ const results = applySortToResults(rawResults, sortParam);
+
+ const total =
+ results.bills.length +
+ results.members.length +
+ results.executive.length +
+ results.awards.length +
+ results.states.length;
+
+ const activeType: ResultType =
+ ["all", "bills", "members", "executive", "awards", "states"].includes(typeParam)
+ ? typeParam
+ : "all";
+
+ const showBills = activeType === "all" || activeType === "bills";
+ const showMembers = activeType === "all" || activeType === "members";
+ const showExec = activeType === "all" || activeType === "executive";
+ const showAwards = activeType === "all" || activeType === "awards";
+ const showStates = activeType === "all" || activeType === "states";
+
+ const visibleTotal =
+ (showBills ? results.bills.length : 0) +
+ (showMembers ? results.members.length : 0) +
+ (showExec ? results.executive.length : 0) +
+ (showAwards ? results.awards.length : 0) +
+ (showStates ? results.states.length : 0);
+
+ return (
+ <div>
+ <SearchBox initialValue={q} />
+
+ {!q ? (
+ <div className="mt-6">
+ <p className="label mb-3 text-ash-2">Try a starting point</p>
+ <div className="flex flex-wrap gap-2">
+ {["tax", "defense", "California", "appropriations", "Texas", "immigration"].map(
+ (chip) => (
+ <Link
+ key={chip}
+ href={`/search?q=${encodeURIComponent(chip)}`}
+ className="focus-ring label inline-flex min-h-[44px] items-center rounded-[2px] border border-line bg-surface px-4 text-ash underline-offset-4 transition-colors hover:bg-graphite hover:text-paper"
+ >
+ {chip}
+ </Link>
+ )
+ )}
+ </div>
+ </div>
+ ) : null}
+
+ {loading ? (
+ <div className="mt-10">
+ <SearchSkeleton />
+ </div>
+ ) : q ? (
+ <div className="mt-10">
+ {/* Type filter row + sort control */}
+ <div className="mb-6 flex flex-wrap items-center justify-between gap-3">
+ {/* Type chips */}
+ <div className="flex flex-wrap gap-2" role="group" aria-label="Filter results by type">
+ {(["all", "bills", "members", "executive", "awards", "states"] as ResultType[]).map(
+ (type) => {
+ const count =
+ type === "all"
+ ? total
+ : type === "bills"
+ ? results.bills.length
+ : type === "members"
+ ? results.members.length
+ : type === "executive"
+ ? results.executive.length
+ : type === "awards"
+ ? results.awards.length
+ : results.states.length;
+ if (type !== "all" && count === 0) return null;
+ const active = activeType === type;
+ return (
+ <Link
+ key={type}
+ href={buildHref(q, type, sortParam)}
+ aria-current={active ? "true" : undefined}
+ className={[
+ "focus-ring label inline-flex min-h-[44px] items-center rounded-[2px] border px-3 transition-colors",
+ active
+ ? "border-paper bg-surface text-paper"
+ : "border-line bg-surface text-ash hover:bg-graphite hover:text-paper",
+ ].join(" ")}
+ >
+ {TYPE_LABELS[type]}
+ {count > 0 ? (
+ <span className="ml-1.5 tabular-nums text-ash-2">{count}</span>
+ ) : null}
+ </Link>
+ );
+ }
+ )}
+ </div>
+
+ {/* Sort control - GET form preserving q + type */}
+ <form method="get" action="/search" className="flex items-center gap-2">
+ <input type="hidden" name="q" value={q} />
+ {activeType !== "all" && (
+ <input type="hidden" name="type" value={activeType} />
+ )}
+ <label htmlFor="search-sort" className="label text-ash-2">
+ Sort
+ </label>
+ <select
+ id="search-sort"
+ name="sort"
+ defaultValue={sortParam}
+ aria-label="Sort search results"
+ className="focus-ring min-h-[44px] rounded-[3px] border border-line bg-surface px-3 py-2 font-mono text-[13px] text-paper"
+ >
+ <option value="">Relevance / newest</option>
+ <option value="alpha">Alphabetical (A-Z)</option>
+ <option value="amount">By amount (awards)</option>
+ </select>
+ <button
+ type="submit"
+ className="focus-ring label min-h-[44px] rounded-[3px] border border-line bg-surface px-3 text-paper hover:bg-graphite"
+ >
+ Apply
+ </button>
+ </form>
+ </div>
+
+ <p className="label mb-6 text-ash">
+ {visibleTotal} result{visibleTotal === 1 ? "" : "s"} for &ldquo;{q}&rdquo;
+ {activeType !== "all" ? ` · ${TYPE_LABELS[activeType]}` : ""}
+ </p>
+
+ {visibleTotal === 0 ? (
+ <Card className="p-8 text-center">
+ <p className="display text-2xl text-paper">No matches on record.</p>
+ <p className="prose-lead mx-auto mt-3 max-w-md text-ash">
+ Try a state code, a bill number, an agency, or a member&apos;s name.
+ </p>
+ </Card>
+ ) : (
+ <div className="space-y-10">
+ {showMembers && results.members.length > 0 ? (
+ <ResultGroup title="Members" count={results.members.length}>
+ {results.members.map((m) => (
+ <Link
+ key={m.id}
+ href={m.href}
+ className="focus-ring group flex items-center gap-3 border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60"
+ >
+ <MemberAvatar name={m.name} photoUrl={m.photoUrl} size="sm" />
+ <span className="min-w-0">
+ <span className="block font-mono text-[13px] text-paper group-hover:underline">
+ {m.name}
+ </span>
+ <span className="label text-ash-2">
+ {m.chamber === "senate" ? "Senate" : "House"} · {m.state}
+ {m.district != null ? ` ${m.district}` : ""} · {m.party}
+ </span>
+ </span>
+ </Link>
+ ))}
+ </ResultGroup>
+ ) : null}
+
+ {showBills && results.bills.length > 0 ? (
+ <ResultGroup title="Bills" count={results.bills.length}>
+ {results.bills.map((b) => (
+ <Link
+ key={b.id}
+ href={b.href}
+ className="focus-ring group block border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60"
+ >
+ <p className="font-mono text-[13px] text-paper group-hover:underline">
+ {b.number} · {b.congress}th Congress
+ </p>
+ <p className="font-mono text-[12px] text-ash">{b.title}</p>
+ <p className="label mt-1 text-ash-2">{b.policyArea}</p>
+ </Link>
+ ))}
+ </ResultGroup>
+ ) : null}
+
+ {showExec && results.executive.length > 0 ? (
+ <ResultGroup title="Federal Register" count={results.executive.length}>
+ {results.executive.map((e) => (
+ <div
+ key={e.id}
+ className="flex items-start justify-between gap-3 border-b border-line p-3 last:border-0"
+ >
+ <div className="min-w-0">
+ <p className="font-mono text-[13px] text-paper">{e.title}</p>
+ <p className="label mt-1 text-ash-2">
+ {e.agency}
+ {e.date ? ` · ${e.date.slice(0, 10)}` : ""}
+ </p>
+ </div>
+ <SourceLink url={e.sourceUrl} label="FR" />
+ </div>
+ ))}
+ </ResultGroup>
+ ) : null}
+
+ {showAwards && results.awards.length > 0 ? (
+ <ResultGroup title="Federal awards" count={results.awards.length}>
+ {results.awards.map((a) => (
+ <div
+ key={a.id}
+ className="flex items-start justify-between gap-3 border-b border-line p-3 last:border-0"
+ >
+ <div className="min-w-0">
+ <p className="font-mono text-[13px] text-paper">{a.agency}</p>
+ <p className="label mt-1 text-ash-2">{a.recipient}</p>
+ </div>
+ <SourceLink url={a.sourceUrl} label="USAspending" />
+ </div>
+ ))}
+ </ResultGroup>
+ ) : null}
+
+ {showStates && results.states.length > 0 ? (
+ <ResultGroup title="States" count={results.states.length}>
+ {results.states.map((s) => (
+ <Link
+ key={s.code}
+ href={`/states/${s.code}`}
+ className="focus-ring group block border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60"
+ >
+ <p className="font-mono text-[13px] text-paper group-hover:underline">
+ {s.code} · {s.name}
+ </p>
+ </Link>
+ ))}
+ </ResultGroup>
+ ) : null}
+ </div>
+ )}
+ </div>
+ ) : null}
+ </div>
+ );
+}
components/spending-awards-browser.tsx +154 -0
@@ -0,0 +1,154 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import { Card, 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,
+ FilterSelect,
+ FilterSubmit,
+} from "@/components/search-filter-bar";
+import type { Award } from "@/lib/feed";
+
+function AwardsSkeleton() {
+ return (
+ <div className="mt-8 space-y-4">
+ <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-8 mb-4 h-4 w-48 animate-pulse rounded-sm bg-graphite" />
+ <div className="space-y-1">
+ {Array.from({ length: 10 }).map((_, i) => (
+ <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function AwardsTable({ awards }: { awards: Award[] }) {
+ const columns: Column[] = [
+ { key: "agency", header: "Agency", cardTitle: true },
+ { key: "recipient", header: "Recipient" },
+ { key: "program", header: "Program", hideOnCard: true },
+ { key: "date", header: "Date" },
+ { key: "amount", header: "Amount", align: "right" },
+ { key: "source", header: "Source", align: "right" },
+ ];
+ const rows: Row[] = awards.map((a) => ({
+ key: a.id,
+ cells: {
+ agency: <span className="text-paper">{a.agency}</span>,
+ recipient: <span className="text-ash">{a.recipient}</span>,
+ program: <span className="text-ash">{a.program}</span>,
+ date: <span className="tabular-nums text-ash-2">{formatDate(a.actionDate)}</span>,
+ amount: <span className="tabular-nums text-paper">{a.amountText}</span>,
+ source: <SourceLink url={a.sourceUrl} />,
+ },
+ }));
+ return (
+ <DataTable
+ caption="Federal awards: agency, recipient, program, date, amount, source."
+ cardLabel="Federal awards"
+ columns={columns}
+ rows={rows}
+ />
+ );
+}
+
+export function SpendingAwardsBrowser() {
+ const sp = useSearchParams();
+ const q = sp.get("q") ?? "";
+ const sortParam = sp.get("sort") ?? "";
+
+ const [awards, setAwards] = useState<Award[]>([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(true);
+ const params = new URLSearchParams();
+ if (q) params.set("q", q);
+ if (sortParam) params.set("sort", sortParam);
+ params.set("limit", "100");
+
+ fetch(`/api/spending/awards?${params.toString()}`)
+ .then((r) => r.json())
+ .then((data) => {
+ setAwards(data.items ?? []);
+ })
+ .catch(() => {
+ setAwards([]);
+ })
+ .finally(() => setLoading(false));
+ }, [q, sortParam]);
+
+ const isFiltered = !!(q || sortParam);
+ const totalAwards = awards.reduce((n, a) => n + a.amount, 0);
+
+ if (loading) {
+ return <AwardsSkeleton />;
+ }
+
+ return (
+ <div>
+ {/* Awards search filter */}
+ <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>
+ <FilterField label="Sort by">
+ <FilterSelect
+ name="sort"
+ defaultValue={sortParam}
+ aria-label="Sort awards"
+ options={[
+ { value: "", label: "Largest amount first" },
+ { value: "date", label: "Most recent first" },
+ { value: "agency", label: "By agency (A-Z)" },
+ ]}
+ />
+ </FilterField>
+ <FilterSubmit />
+ </SearchFilterBar>
+ </form>
+ </div>
+
+ {/* Awards table */}
+ <div className="mt-12">
+ <SectionHeader
+ eyebrow="Federal awards"
+ title="Contracts, grants, and assistance"
+ 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 match.</p>
+ <p className="prose-lead mx-auto mt-3 max-w-md text-ash">
+ Adjust the search above. Federal awards appear as the ingest runs.
+ </p>
+ </Card>
+ ) : (
+ <AwardsTable awards={awards} />
+ )}
+ <p className="label mt-4 text-ash-2">
+ Source: USAspending.gov. Figures reflect recorded award obligations;
+ the source link on each row opens the official record.
+ </p>
+ </div>
+ </div>
+ );
+}
components/votes-browser.tsx +228 -0
@@ -0,0 +1,228 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import Link from "next/link";
+import { Card, 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 {
+ SearchFilterBar,
+ FilterField,
+ FilterInput,
+ FilterSelect,
+ FilterSubmit,
+} from "@/components/search-filter-bar";
+import { resolveVoteSubject } from "@/lib/vote-subject";
+import { formatDate } from "@/lib/format";
+import type { Vote } from "@/lib/feed";
+
+/** Truncate to max chars, appending ellipsis. */
+function trunc(s: string | undefined, max: number): string {
+ if (!s) return "";
+ return s.length <= max ? s : s.slice(0, max - 1) + "…";
+}
+
+function VotesSkeleton() {
+ return (
+ <div className="mt-8 space-y-4">
+ <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" />
+ <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" />
+ <div className="space-y-1">
+ {Array.from({ length: 16 }).map((_, i) => (
+ <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function VotesTable({ votes }: { votes: Vote[] }) {
+ const columns: Column[] = [
+ { key: "date", header: "Date" },
+ { key: "chamber", header: "Chamber", hideOnCard: true },
+ { key: "what", header: "What was voted on", cardTitle: true },
+ { key: "tally", header: "Tally", align: "right" },
+ { key: "record", header: "Record", align: "right" },
+ ];
+ const rows: Row[] = votes.map((v) => {
+ const variant = classifyStatus(v.result);
+ const subject = resolveVoteSubject(v);
+ return {
+ key: v.id,
+ cells: {
+ date: <span className="tabular-nums text-ash">{formatDate(v.date)}</span>,
+ chamber: <span className="uppercase text-ash-2">{v.chamber}</span>,
+ what: (
+ <span className="block text-paper">
+ <Link
+ href={v.href ?? "#"}
+ className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
+ >
+ <span
+ className={
+ subject.kind === "bill"
+ ? "block font-mono text-[13px] text-paper"
+ : "block text-paper"
+ }
+ >
+ {trunc(subject.primary, 90)}
+ </span>
+ {subject.secondary ? (
+ <span className="mt-0.5 block text-[12px] text-ash-2">
+ {trunc(subject.secondary, 72)}
+ </span>
+ ) : null}
+ </Link>
+ <span className="mt-1.5 block">
+ <StatusPill variant={variant} dot>
+ {v.recorded ? "Recorded" : "Voice / UC"}
+ </StatusPill>
+ </span>
+ </span>
+ ),
+ tally: (
+ <span className="tabular-nums text-paper">
+ {v.recorded ? `${v.yea}-${v.nay}` : "-"}
+ </span>
+ ),
+ record: <SourceLink url={v.sourceUrl} />,
+ },
+ };
+ });
+ return (
+ <DataTable
+ caption="Recorded votes: date, chamber, what was voted on, tally, and source."
+ cardLabel="Recorded votes"
+ columns={columns}
+ rows={rows}
+ />
+ );
+}
+
+export function VotesBrowser() {
+ const sp = useSearchParams();
+ const chamber = sp.get("chamber") ?? "";
+ const q = sp.get("q") ?? "";
+ const result = sp.get("result") ?? "";
+ const sort = sp.get("sort") ?? "";
+
+ const [votes, setVotes] = useState<Vote[]>([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(true);
+ const params = new URLSearchParams();
+ if (chamber) params.set("chamber", chamber);
+ if (q) params.set("q", q);
+ if (result) params.set("result", result);
+ if (sort) params.set("sort", sort);
+ params.set("limit", "100");
+
+ fetch(`/api/votes?${params.toString()}`)
+ .then((r) => r.json())
+ .then((data) => {
+ setVotes(data.items ?? []);
+ setTotal(data.total ?? 0);
+ })
+ .catch(() => {
+ setVotes([]);
+ setTotal(0);
+ })
+ .finally(() => setLoading(false));
+ }, [chamber, q, result, sort]);
+
+ const isFiltered = !!(chamber || q || result || sort);
+ const clearHref = "/votes";
+
+ if (loading) {
+ return <VotesSkeleton />;
+ }
+
+ return (
+ <div>
+ {/* Search/filter bar - client-side via GET form. */}
+ <div className="mt-8">
+ <form method="get" action="/votes" className="contents">
+ <SearchFilterBar
+ count={votes.length < total ? votes.length : total}
+ total={total}
+ countLabel="vote"
+ clearHref={isFiltered ? clearHref : undefined}
+ >
+ <FilterField label="Search votes" span="2">
+ <FilterInput
+ name="q"
+ defaultValue={q}
+ placeholder="Keyword, bill number, 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>
+ <FilterField label="Result">
+ <FilterSelect
+ name="result"
+ defaultValue={result}
+ aria-label="Filter by vote result"
+ options={[
+ { value: "", label: "All results" },
+ { value: "passed", label: "Passed (Yea majority)" },
+ { value: "failed", label: "Failed (Nay majority)" },
+ ]}
+ />
+ </FilterField>
+ <FilterField label="Sort by">
+ <FilterSelect
+ name="sort"
+ defaultValue={sort}
+ aria-label="Sort votes"
+ options={[
+ { value: "", label: "Newest first" },
+ { value: "chamber", label: "By chamber" },
+ { value: "result", label: "By result text" },
+ ]}
+ />
+ </FilterField>
+ <FilterSubmit />
+ </SearchFilterBar>
+ </form>
+ </div>
+
+ <div className="mt-8">
+ <SectionHeader
+ eyebrow={`${total.toLocaleString()} vote${total === 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 match.</p>
+ <p className="prose-lead mx-auto mt-3 max-w-md text-ash">
+ Adjust the filter or search above. The roll-call register covers
+ recorded House and Senate votes.
+ </p>
+ </Card>
+ ) : (
+ <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.
+ </p>
+ </div>
+ </div>
+ );
+}
next.config.js +13 -0
@@ -43,6 +43,19 @@ const nextConfig = {
return [
// Security headers on every route.
{ source: "/:path*", headers: securityHeaders },
+ // Cache-Control for page routes that are now static shells (no searchParams).
+ // These pages no longer read searchParams in server components, so Next.js
+ // will NOT emit Cache-Control: private, no-store. This header ensures CF
+ // caches the rendered HTML at the edge for 5 minutes.
+ {
+ source: "/(votes|bills|bill|members|spending|executive|search|states)(.*)?",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "public, s-maxage=300, stale-while-revalidate=600",
+ },
+ ],
+ },
// Cache-Control for read-only GET API routes.
// Docket is a public read-only site; 5-minute staleness is acceptable.
// s-maxage lets the Cloudflare edge cache serve repeats without hitting