| @@ -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. |
| @@ -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]) |
| @@ -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)) |
| @@ -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. |
| @@ -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); | ||
| } |
| @@ -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); | ||
| } |
| @@ -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"; | |
| - | } |
| @@ -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 “{q}” | |
| - | {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'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> | ||
| ); | ||
| } |
| @@ -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, |
| @@ -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> | |
| ); | ||
| } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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 “{q}” | |
| + | {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'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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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 |