| @@ -0,0 +1,134 @@ | ||
| + | # Docket - Performance Audit & Fix Notes | |
| + | ||
| + | Audit and fix session conducted 2026-06-26/27. All timings are server-side | |
| + | response time measured with `curl -o /dev/null -w "%{time_total}"` against | |
| + | CT247's local Next.js listener (`http://localhost:3000`). "First hit" is a | |
| + | cold request; "second hit" is a repeat within the same 5-minute TTL window. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Before / After | |
| + | ||
| + | | Route | Before | After (1st) | After (2nd) | Factor | | |
| + | |--------------------|----------|-------------|-------------|--------| | |
| + | | `/register` | 2.92 s | 0.18 s | 0.12 s | 16x | | |
| + | | `/votes` | 0.57 s | 0.11 s | 0.06 s | 9x | | |
| + | | `/members` | 2.02 s | 0.12 s | 0.10 s | 20x | | |
| + | | `/bills` | 2.69 s | 0.28 s | 0.20 s | 13x | | |
| + | | `/spending` | 0.61 s | 0.29 s | 0.25 s | 2x | | |
| + | | `/members/A000055` | 32.31 s | 0.11 s | 0.09 s | 294x | | |
| + | | `/states` | 0.097 s | 0.025 s | 0.026 s | 4x | | |
| + | | `/calendar` | 1.07 s | 0.06 s | 0.05 s | 21x | | |
| + | ||
| + | QA: **CLEAN** - 17 routes, no overflow, no hydration errors, confirmed on | |
| + | every rebuild. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Root Causes Fixed | |
| + | ||
| + | ### 1. Full-table scans passed to JS for filtering | |
| + | ||
| + | `getBills()`, `getVotes()`, `getFeedItems()` all loaded entire tables into | |
| + | memory (208k bills, 35k votes, all feed rows) and sliced/filtered in JS. | |
| + | ||
| + | Fix: pushed `WHERE`, `LIMIT`, and `OFFSET` into SQL for all three. New | |
| + | helpers: `buildBillsWhere(filter)`, `getBillsCount(filter)`, `getVoteCount(chamber?)`, | |
| + | `getFeedKindCounts()`. | |
| + | ||
| + | ### 2. Missing indexes | |
| + | ||
| + | The `bills` table had no index on `sponsor_id` - `getMember()` did a full | |
| + | 208k-row scan to find each member's sponsored bills. Added 11 indexes via a | |
| + | Python retry script (to survive concurrent write locks from `docket-photos.service`): | |
| + | ||
| + | ``` | |
| + | idx_bills_sponsor_id ON bills(sponsor_id) | |
| + | idx_bills_policy_area ON bills(policy_area) | |
| + | idx_bills_status ON bills(status) | |
| + | idx_bills_congress_chamber ON bills(congress, chamber) | |
| + | idx_bills_updated_at ON bills(updated_at) | |
| + | idx_vote_positions_member_id ON vote_positions(member_id) (was missing) | |
| + | idx_vote_positions_vote_id ON vote_positions(vote_id) (was missing) | |
| + | idx_votes_year ON votes(year) | |
| + | idx_votes_year_chamber ON votes(year, chamber) | |
| + | idx_awards_action_date ON awards(action_date) | |
| + | idx_calendar_events_date ON calendar_events(date) | |
| + | ``` | |
| + | ||
| + | ### 3. Double getMember() call per request | |
| + | ||
| + | `generateMetadata` AND `MemberDetailPage` both call `getMember(params.id)`, | |
| + | which runs 6+ SQLite queries. Wrapped with the in-process TTL cache so the | |
| + | second call returns instantly from `_cache`. | |
| + | ||
| + | ### 4. 1500 MemberVote objects serialised into RSC wire format | |
| + | ||
| + | For long-serving members (e.g. Aderholt: 18,458 positions), the year-by-year | |
| + | section loaded 1500 vote rows and serialised them all into the initial HTML. | |
| + | This kept the page at ~1.8 s even after the index and cache fixes. | |
| + | ||
| + | Fix: replaced the 1500-row join in `_getMemberImpl` with a single cheap | |
| + | `GROUP BY year` query. Each year's votes are now fetched lazily by the new | |
| + | `MemberVoteYear` client component when the `<details>` panel is first opened. | |
| + | New API route: `GET /api/members/[id]/votes?year=YYYY`. | |
| + | ||
| + | Result: 1.81 s → 0.11 s for A000055. | |
| + | ||
| + | ### 5. Members roster - 16 MB SSR payload | |
| + | ||
| + | `getMembersDefault()` originally returned `isCurrent || !!photoUrl` = 10,326 | |
| + | members (nearly all have photos after the photo-sync service ran). Changed to | |
| + | `isCurrent` only → 537 current-Congress members. Historical members load | |
| + | lazily via paginated `/api/members` calls in `MembersBrowser` when the toggle | |
| + | is clicked. | |
| + | ||
| + | ### 6. Calendar page - 6.8 MB event serialisation | |
| + | ||
| + | `getCalendarEvents()` returned 2,737 full `CalendarEvent` objects for the | |
| + | current month. The grid only needed one dot-per-kind-per-day. Changed to | |
| + | `getCalendarDaySummary(from, to)` which uses `SELECT date, kind, COUNT(*) | |
| + | GROUP BY date, kind`. Full events for the selected day are fetched separately. | |
| + | ||
| + | ### 7. Register page - getAwards + double byKind | |
| + | ||
| + | `getAwards()` loaded the full awards table just to count rows and sum amounts. | |
| + | Replaced with `getAwardsSummary()` (single `SELECT COUNT(*), SUM(amount)`). | |
| + | `byKind(items)` was computed twice in JSX - now computed once as `kindBreakdown`. | |
| + | ||
| + | ### 8. force-dynamic → revalidate = 300 | |
| + | ||
| + | All list and detail pages were `force-dynamic` (re-render every request). | |
| + | Changed to `revalidate = 300` (ISR, 5-minute stale-while-revalidate): | |
| + | `/register`, `/votes`, `/members`, `/bills`, `/spending`, `/states`, | |
| + | `/calendar`, `/executive`, `/categories`, `/search`, `/schedule`, | |
| + | `/states/[code]`, `/votes/[id]`, `/bill/[id]`, `/members/[id]`. | |
| + | ||
| + | ### 9. In-process TTL cache | |
| + | ||
| + | Added a `Map<string, {value, expiry}>` cache in `lib/feed.ts` with a 5-minute | |
| + | TTL. Expensive aggregates that ISR might call multiple times in one render | |
| + | cycle (member lookup, category lists, member counts, all-members roster, | |
| + | awards summary) are now served from memory on repeat calls within the window. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Files Changed | |
| + | ||
| + | - `lib/feed.ts` - cache helper, SQL pagination, new query functions, | |
| + | `getMemberVotesForYear`, lazy year-summary `votesByYear` | |
| + | - `app/register/page.tsx` - revalidate, `getAwardsSummary`, single `byKind` | |
| + | - `app/votes/page.tsx` - revalidate, paginated `getVotes(100,0)`, `getVoteCount` | |
| + | - `app/bills/page.tsx` - revalidate, SQL `getBills` + `getBillsCount` | |
| + | - `app/members/page.tsx` - revalidate, `getMemberCounts` + `getMembersDefault` | |
| + | - `app/members/[id]/page.tsx` - revalidate, `MemberVoteYear` lazy panels | |
| + | - `app/spending/page.tsx`, `app/states/page.tsx`, `app/executive/page.tsx`, | |
| + | `app/states/[code]/page.tsx`, `app/votes/[id]/page.tsx`, `app/bill/[id]/page.tsx`, | |
| + | `app/categories/page.tsx`, `app/search/page.tsx`, `app/schedule/page.tsx` - revalidate | |
| + | - `app/calendar/page.tsx` - revalidate, condensed `getCalendarDaySummary` | |
| + | - `app/api/bills/route.ts` - SQL pagination | |
| + | - `app/api/votes/route.ts` - SQL pagination | |
| + | - `app/api/members/route.ts` - `getMemberCounts` for cheap total | |
| + | - `app/api/members/[id]/votes/route.ts` - new, lazy year votes | |
| + | - `components/members-browser.tsx` - lazy-load historical members | |
| + | - `components/member-vote-year.tsx` - new, lazy year accordion |
| @@ -1,6 +1,6 @@ | ||
| import { NextResponse, type NextRequest } from "next/server"; | ||
| import { parsePaging, type ItemList } from "@/lib/db"; | ||
| - | import { getBills, type BillListItem } from "@/lib/feed"; | |
| + | import { getBills, getBillsCount, type BillListItem } from "@/lib/feed"; | |
| export const dynamic = "force-dynamic"; | ||
| @@ -10,17 +10,18 @@ export async function GET(req: NextRequest) { | ||
| const { limit, offset } = parsePaging(sp); | ||
| const congressRaw = parseInt(sp.get("congress") ?? "", 10); | ||
| - | let items = getBills({ | |
| + | const filter = { | |
| q: sp.get("q") || undefined, | ||
| state: sp.get("state") || undefined, | ||
| status: sp.get("status") || undefined, | ||
| congress: Number.isFinite(congressRaw) ? congressRaw : undefined, | ||
| policyArea: sp.get("policyArea") || undefined, | ||
| - | sort: sp.get("sort") === "action" ? "action" : "updated", | |
| - | }); | |
| + | sort: sp.get("sort") === "action" ? "action" as const : "updated" as const, | |
| + | }; | |
| - | const total = items.length; | |
| - | const paged = items.slice(offset, offset + limit); | |
| - | const body: ItemList<BillListItem> = { items: paged, total, limit, offset }; | |
| + | // Push all filtering and pagination into SQL - no more full-table JS slice. | |
| + | const items = getBills(filter, limit, offset); | |
| + | const total = getBillsCount(filter); | |
| + | const body: ItemList<BillListItem> = { items, total, limit, offset }; | |
| return NextResponse.json(body); | ||
| } |
| @@ -0,0 +1,27 @@ | ||
| + | import { NextResponse, type NextRequest } from "next/server"; | |
| + | import { getMemberVotesForYear } from "@/lib/feed"; | |
| + | ||
| + | export const dynamic = "force-dynamic"; | |
| + | ||
| + | /** | |
| + | * GET /api/members/[id]/votes?year=2025 | |
| + | * | |
| + | * Returns the votes a member cast in a given year (or all undated votes when | |
| + | * year=null). Used by the MemberVoteYear client component to lazy-load | |
| + | * per-year vote rows only when the <details> panel is first opened. | |
| + | * | |
| + | * [id] is the bioguide ID (e.g. "A000055"). | |
| + | * ?year= - omit or pass "null" for undated votes; pass a 4-digit year otherwise. | |
| + | */ | |
| + | export async function GET( | |
| + | req: NextRequest, | |
| + | { params }: { params: { id: string } } | |
| + | ) { | |
| + | const bioguide = params.id.toUpperCase(); | |
| + | const yearStr = req.nextUrl.searchParams.get("year"); | |
| + | const year: number | null = | |
| + | yearStr && yearStr !== "null" ? parseInt(yearStr, 10) : null; | |
| + | ||
| + | const votes = getMemberVotesForYear(bioguide, year); | |
| + | return NextResponse.json({ votes, year }); | |
| + | } |
| @@ -1,6 +1,6 @@ | ||
| import { NextResponse, type NextRequest } from "next/server"; | ||
| import { parsePaging, type ItemList } from "@/lib/db"; | ||
| - | import { getMembers, type MemberSummary } from "@/lib/feed"; | |
| + | import { getMembers, getMemberCounts, type MemberSummary } from "@/lib/feed"; | |
| import type { PartyCode } from "@/lib/neutrality"; | ||
| export const dynamic = "force-dynamic"; | ||
| @@ -17,14 +17,18 @@ export async function GET(req: NextRequest) { | ||
| ? "I" | ||
| : undefined; | ||
| - | let items = getMembers({ | |
| + | const filter = { | |
| q: sp.get("q") || undefined, | ||
| state: sp.get("state") || undefined, | ||
| chamber: sp.get("chamber") || undefined, | ||
| party: party as PartyCode | undefined, | ||
| - | }); | |
| + | }; | |
| - | const total = items.length; | |
| + | const items = getMembers(filter); | |
| + | // Use cached count - avoids length() on 12.7k array on every API request. | |
| + | const total = filter.q || filter.state || filter.chamber || filter.party | |
| + | ? items.length | |
| + | : getMemberCounts().total; | |
| const paged = items.slice(offset, offset + limit); | ||
| const body: ItemList<MemberSummary> = { items: paged, total, limit, offset }; | ||
| return NextResponse.json(body); |
| @@ -1,6 +1,6 @@ | ||
| import { NextResponse, type NextRequest } from "next/server"; | ||
| import { parsePaging, type ItemList } from "@/lib/db"; | ||
| - | import { getVotes, type Vote } from "@/lib/feed"; | |
| + | import { getVotes, getVoteCount, type Vote } from "@/lib/feed"; | |
| export const dynamic = "force-dynamic"; | ||
| @@ -8,14 +8,11 @@ export const dynamic = "force-dynamic"; | ||
| export async function GET(req: NextRequest) { | ||
| const sp = req.nextUrl.searchParams; | ||
| const { limit, offset } = parsePaging(sp); | ||
| - | const chamber = sp.get("chamber")?.toLowerCase(); | |
| + | const chamber = sp.get("chamber")?.toLowerCase() || undefined; | |
| - | let items = getVotes(); | |
| - | if (chamber) { | |
| - | items = items.filter((v) => v.chamber.toLowerCase() === chamber); | |
| - | } | |
| - | const total = items.length; | |
| - | const paged = items.slice(offset, offset + limit); | |
| - | const body: ItemList<Vote> = { items: paged, total, limit, offset }; | |
| + | // Push filtering and pagination into SQL - no more full-table JS slice. | |
| + | const items = getVotes(limit, offset, chamber); | |
| + | const total = getVoteCount(chamber); | |
| + | const body: ItemList<Vote> = { items, total, limit, offset }; | |
| return NextResponse.json(body); | ||
| } |
| @@ -11,7 +11,7 @@ import { RiderFlag, ChangeTag } from "@/components/rider-flag"; | ||
| import { getBillDetail, type BillDiffChange, type BillAmendment } from "@/lib/feed"; | ||
| import { formatDate } from "@/lib/format"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export function generateMetadata({ | ||
| params, |
| @@ -4,10 +4,10 @@ import { Card, PageHeader, SectionHeader } from "@/components/ui"; | ||
| import { DataTable, type Column, type Row } from "@/components/data-table"; | ||
| import { SourceLink } from "@/components/source-link"; | ||
| import { StatusPill, classifyStatus } from "@/components/status-pill"; | ||
| - | import { getBills, getPolicyAreaLabels, getBillCongresses, type BillListItem } from "@/lib/feed"; | |
| + | import { getBills, getBillsCount, getPolicyAreaLabels, getBillCongresses, type BillListItem } from "@/lib/feed"; | |
| import { formatDate } from "@/lib/format"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export const metadata: Metadata = { | ||
| title: "Bills", | ||
| @@ -30,13 +30,15 @@ export default function BillsPage({ | ||
| const congressRaw = asString(searchParams.congress); | ||
| const congressNum = parseInt(congressRaw ?? "", 10); | ||
| - | const allBills = getBills({ | |
| + | const billFilter = { | |
| policyArea, | ||
| q, | ||
| congress: Number.isFinite(congressNum) ? congressNum : undefined, | ||
| - | sort: "updated", | |
| - | }); | |
| - | const bills = allBills.slice(0, 100); // render cap; full count in eyebrow | |
| + | sort: "updated" as const, | |
| + | }; | |
| + | // Push filtering into SQL - fetch only the 100 we render + a cheap count. | |
| + | const bills = getBills(billFilter, 100, 0); | |
| + | const billsTotal = getBillsCount(billFilter); | |
| const policyAreas = getPolicyAreaLabels(); | ||
| const congresses = getBillCongresses(); | ||
| @@ -132,7 +134,7 @@ export default function BillsPage({ | ||
| <div className="mt-8"> | ||
| <SectionHeader | ||
| - | eyebrow={`${allBills.length} bill${allBills.length === 1 ? "" : "s"} · showing ${bills.length}`} | |
| + | eyebrow={`${billsTotal.toLocaleString()} bill${billsTotal === 1 ? "" : "s"} · showing ${bills.length}`} | |
| title={policyArea ? policyArea : "On record"} | ||
| /> | ||
| {bills.length === 0 ? ( |
| @@ -2,10 +2,16 @@ import type { Metadata } from "next"; | ||
| import Link from "next/link"; | ||
| import { Card, PageHeader, SectionHeader } from "@/components/ui"; | ||
| import { SourceLink } from "@/components/source-link"; | ||
| - | import { getCalendarEvents, type CalendarEvent, type CalendarKind } from "@/lib/feed"; | |
| + | import { | |
| + | getCalendarEvents, | |
| + | getCalendarDaySummary, | |
| + | type CalendarEvent, | |
| + | type CalendarKind, | |
| + | type CalendarDayKind, | |
| + | } from "@/lib/feed"; | |
| import { clsx } from "@/lib/cx"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export const metadata: Metadata = { | ||
| title: "Calendar", | ||
| @@ -87,24 +93,34 @@ export default function CalendarPage({ | ||
| const from = `${year}-${pad(month)}-01`; | ||
| const to = `${year}-${pad(month)}-${pad(daysInMonth)}`; | ||
| - | // Events for the whole visible month (filtered by kind when requested). | |
| - | const monthEvents = getCalendarEvents({ from, to, kind: kindFilter }); | |
| - | const byDate = new Map<string, CalendarEvent[]>(); | |
| - | for (const e of monthEvents) { | |
| - | const arr = byDate.get(e.date) ?? []; | |
| - | arr.push(e); | |
| - | byDate.set(e.date, arr); | |
| + | // Grid: use condensed per-day kind counts (avoids serializing 2,700+ full | |
| + | // CalendarEvent objects). Only the selected-day detail panel needs full rows. | |
| + | const daySummary = getCalendarDaySummary(from, to); | |
| + | // Filter grid summary by kind if requested. | |
| + | const filteredSummary = kindFilter | |
| + | ? daySummary.filter((s) => s.kind === kindFilter) | |
| + | : daySummary; | |
| + | const byDate = new Map<string, CalendarDayKind[]>(); | |
| + | for (const s of filteredSummary) { | |
| + | const arr = byDate.get(s.date) ?? []; | |
| + | arr.push(s); | |
| + | byDate.set(s.date, arr); | |
| } | ||
| + | // Full events for the selected day only (shown in the detail panel). | |
| + | const dayEvents: CalendarEvent[] = selectedDay | |
| + | ? getCalendarEvents({ from: selectedDay, to: selectedDay, kind: kindFilter }) | |
| + | : []; | |
| + | ||
| + | // Kind counts for the legend: sum from the condensed summary. | |
| + | const kindCounts = countKindsFromSummary(filteredSummary); | |
| + | ||
| const prev = shiftMonth(year, month, -1); | ||
| const next = shiftMonth(year, month, +1); | ||
| const prevHref = hrefFor(prev, selectedDay, kindFilter); | ||
| const nextHref = hrefFor(next, selectedDay, kindFilter); | ||
| const todayHref = hrefFor({ year: todayYear(), month: todayMonth() }, todayIso, kindFilter); | ||
| - | const dayEvents = selectedDay ? byDate.get(selectedDay) ?? [] : []; | |
| - | const kindCounts = countKinds(monthEvents); | |
| - | ||
| // Build the grid cells (leading blanks, days, trailing blanks to fill rows). | ||
| const cells: Array<number | null> = []; | ||
| for (let i = 0; i < firstWeekday; i++) cells.push(null); | ||
| @@ -232,6 +248,7 @@ export default function CalendarPage({ | ||
| } | ||
| const dateStr = `${year}-${pad(month)}-${pad(day)}`; | ||
| const evs = byDate.get(dateStr) ?? []; | ||
| + | const evCount = dayEventCount(evs); | |
| const isToday = dateStr === todayIso; | ||
| const isSelected = dateStr === selectedDay; | ||
| const marks = distinctMarks(evs); | ||
| @@ -239,7 +256,7 @@ export default function CalendarPage({ | ||
| <td key={ci} className="border-r border-line p-0 align-top last:border-r-0"> | ||
| <Link | ||
| href={hrefFor({ year, month }, dateStr, kindFilter)} | ||
| - | aria-label={`${day} ${MONTH_NAMES[month - 1]} ${year}${evs.length ? `, ${evs.length} event${evs.length === 1 ? "" : "s"}` : ", no events"}`} | |
| + | aria-label={`${day} ${MONTH_NAMES[month - 1]} ${year}${evCount ? `, ${evCount} event${evCount === 1 ? "" : "s"}` : ", no events"}`} | |
| aria-current={isSelected ? "true" : undefined} | ||
| className={clsx( | ||
| "focus-ring block min-h-[5.5rem] p-2 no-underline", | ||
| @@ -251,14 +268,14 @@ export default function CalendarPage({ | ||
| "flex h-7 w-7 items-center justify-center rounded-full font-mono text-[13px] tabular-nums underline", | ||
| isToday | ||
| ? "bg-signal text-ink" | ||
| - | : evs.length | |
| + | : evCount | |
| ? "text-paper" | ||
| : "text-ash-2" | ||
| )} | ||
| > | ||
| {day} | ||
| </span> | ||
| - | {evs.length > 0 ? ( | |
| + | {evCount > 0 ? ( | |
| <div className="mt-1.5 flex flex-wrap gap-1"> | ||
| {marks.slice(0, 3).map((m, i) => ( | ||
| <span | ||
| @@ -272,9 +289,13 @@ export default function CalendarPage({ | ||
| {m.mark} | ||
| </span> | ||
| ))} | ||
| - | {evs.length > 3 ? ( | |
| + | {marks.length > 3 ? ( | |
| <span className="label !text-[10px] text-ash-2"> | ||
| - | +{evs.length - 3} | |
| + | +{evCount - marks.slice(0, 3).reduce((s, _, i) => s + (evs[i]?.count ?? 0), 0)} | |
| + | </span> | |
| + | ) : evCount > marks.length ? ( | |
| + | <span className="label !text-[10px] text-ash-2"> | |
| + | +{evCount - evs.filter((_, i) => i < 3).reduce((s, e) => s + e.count, 0)} | |
| </span> | ||
| ) : null} | ||
| </div> | ||
| @@ -288,13 +309,21 @@ export default function CalendarPage({ | ||
| </tbody> | ||
| </table> | ||
| <p className="label border-t border-line px-3 py-2 text-ash-2"> | ||
| - | {monthEvents.length} event{monthEvents.length === 1 ? "" : "s"} this month. Select a day to read them. | |
| + | {filteredSummary.reduce((s, e) => s + e.count, 0)} event{filteredSummary.reduce((s, e) => s + e.count, 0) === 1 ? "" : "s"} this month. Select a day to read them. | |
| </p> | ||
| </Card> | ||
| - | {/* MOBILE AGENDA - day-grouped list, replaces the grid on phones. */} | |
| + | {/* MOBILE AGENDA - day summary list, replaces the grid on phones. */} | |
| <div className="md:hidden"> | ||
| - | <MobileAgenda events={monthEvents} kindByValue={KIND_BY_VALUE} /> | |
| + | <MobileAgenda | |
| + | daySummary={filteredSummary} | |
| + | kindByValue={KIND_BY_VALUE} | |
| + | selectedDay={selectedDay} | |
| + | dayEvents={dayEvents} | |
| + | year={year} | |
| + | month={month} | |
| + | kindFilter={kindFilter} | |
| + | /> | |
| </div> | ||
| </div> | ||
| @@ -425,7 +454,8 @@ function chunk<T>(arr: T[], size: number): T[][] { | ||
| return out; | ||
| } | ||
| - | function distinctMarks(evs: CalendarEvent[]): KindMeta[] { | |
| + | /** Distinct kind marks for a day cell (uses condensed summary). */ | |
| + | function distinctMarks(evs: CalendarDayKind[]): KindMeta[] { | |
| const seen = new Set<string>(); | ||
| const out: KindMeta[] = []; | ||
| for (const e of evs) { | ||
| @@ -438,9 +468,15 @@ function distinctMarks(evs: CalendarEvent[]): KindMeta[] { | ||
| return out; | ||
| } | ||
| - | function countKinds(evs: CalendarEvent[]): Map<CalendarKind, number> { | |
| + | /** Total event count for a day cell (sums counts across kinds). */ | |
| + | function dayEventCount(evs: CalendarDayKind[]): number { | |
| + | return evs.reduce((s, e) => s + e.count, 0); | |
| + | } | |
| + | ||
| + | /** Kind counts from the condensed day summary (used by the legend). */ | |
| + | function countKindsFromSummary(summary: CalendarDayKind[]): Map<CalendarKind, number> { | |
| const m = new Map<CalendarKind, number>(); | ||
| - | for (const e of evs) m.set(e.kind, (m.get(e.kind) ?? 0) + 1); | |
| + | for (const s of summary) m.set(s.kind, (m.get(s.kind) ?? 0) + s.count); | |
| return m; | ||
| } | ||
| @@ -457,19 +493,38 @@ function formatLong(iso: string): string { | ||
| } | ||
| /** | ||
| - | * Mobile agenda - day-grouped list of the month's events, used in place of the | |
| - | * month grid on phones (<md). Each day is a header; each event is a compact | |
| - | * card (kind mark + refType, title link, source). Empty when the month has no | |
| - | * events. Reuses the same kind metadata + source links as the grid. | |
| + | * Mobile agenda - shows per-day event counts and kind dots for the month, | |
| + | * with full event details for the selected day. Uses the condensed day summary | |
| + | * to avoid serializing thousands of full CalendarEvent objects into the page. | |
| + | * Tap a day row to navigate to it (URL-driven, same as desktop grid). | |
| */ | ||
| function MobileAgenda({ | ||
| - | events, | |
| + | daySummary, | |
| kindByValue, | ||
| + | selectedDay, | |
| + | dayEvents, | |
| + | year, | |
| + | month, | |
| + | kindFilter, | |
| }: { | ||
| - | events: CalendarEvent[]; | |
| + | daySummary: CalendarDayKind[]; | |
| kindByValue: Map<CalendarKind, KindMeta>; | ||
| + | selectedDay: string | undefined; | |
| + | dayEvents: CalendarEvent[]; | |
| + | year: number; | |
| + | month: number; | |
| + | kindFilter: string | undefined; | |
| }) { | ||
| - | if (events.length === 0) { | |
| + | // Group summary by date. | |
| + | const byDate = new Map<string, CalendarDayKind[]>(); | |
| + | for (const s of daySummary) { | |
| + | const arr = byDate.get(s.date) ?? []; | |
| + | arr.push(s); | |
| + | byDate.set(s.date, arr); | |
| + | } | |
| + | const days = Array.from(byDate.keys()).sort(); | |
| + | ||
| + | if (days.length === 0) { | |
| return ( | ||
| <Card className="p-5"> | ||
| <p className="font-mono text-[13px] text-ash-2"> | ||
| @@ -479,69 +534,86 @@ function MobileAgenda({ | ||
| </Card> | ||
| ); | ||
| } | ||
| - | // Group by date (asc); events already come back ordered by date. | |
| - | const byDate = new Map<string, CalendarEvent[]>(); | |
| - | for (const e of events) { | |
| - | const arr = byDate.get(e.date) ?? []; | |
| - | arr.push(e); | |
| - | byDate.set(e.date, arr); | |
| - | } | |
| - | const days = Array.from(byDate.keys()).sort(); | |
| + | ||
| return ( | ||
| - | <div className="space-y-6"> | |
| + | <div className="space-y-2"> | |
| {days.map((d) => { | ||
| const evs = byDate.get(d)!; | ||
| + | const count = dayEventCount(evs); | |
| + | const marks = distinctMarks(evs); | |
| + | const isSelected = d === selectedDay; | |
| return ( | ||
| - | <section key={d} aria-label={formatLong(d)}> | |
| - | <div className="mb-2 flex items-baseline justify-between gap-3 border-b border-line pb-2"> | |
| - | <h3 className="font-mono text-[13px] text-paper">{formatLong(d)}</h3> | |
| - | <span className="label text-ash-2"> | |
| - | {evs.length} event{evs.length === 1 ? "" : "s"} | |
| - | </span> | |
| - | </div> | |
| - | <div className="space-y-2"> | |
| - | {evs.map((e) => { | |
| - | const k = kindByValue.get(e.kind); | |
| - | return ( | |
| - | <article | |
| - | key={e.id} | |
| - | className="rounded-[3px] border border-line bg-surface p-3" | |
| + | <section key={d}> | |
| + | <Link | |
| + | href={hrefFor({ year, month }, d, kindFilter)} | |
| + | className={`focus-ring block rounded-[3px] border p-3 no-underline hover:bg-graphite/50 ${isSelected ? "border-paper bg-graphite" : "border-line"}`} | |
| + | aria-current={isSelected ? "true" : undefined} | |
| + | > | |
| + | <div className="flex items-center justify-between gap-3"> | |
| + | <span className="font-mono text-[13px] text-paper">{formatLong(d)}</span> | |
| + | <span className="label text-ash-2 shrink-0">{count} event{count === 1 ? "" : "s"}</span> | |
| + | </div> | |
| + | <div className="mt-1.5 flex flex-wrap gap-1"> | |
| + | {marks.map((m, i) => ( | |
| + | <span | |
| + | key={i} | |
| + | className="pill !px-1.5 !py-0" | |
| + | style={{ | |
| + | color: m.color, | |
| + | borderColor: `color-mix(in srgb, ${m.color} 45%, transparent)`, | |
| + | }} | |
| > | ||
| - | <div className="flex flex-wrap items-center gap-2"> | |
| - | {k ? ( | |
| - | <span | |
| - | className="pill !py-0.5" | |
| - | style={{ | |
| - | color: k.color, | |
| - | borderColor: `color-mix(in srgb, ${k.color} 45%, transparent)`, | |
| - | }} | |
| - | > | |
| - | {k.plain} | |
| - | </span> | |
| - | ) : ( | |
| - | <span className="pill !py-0.5 text-ash">{e.kind}</span> | |
| - | )} | |
| - | <span className="label text-ash-2">{e.refType}</span> | |
| - | </div> | |
| - | <p className="mt-2 break-words font-mono text-[12px] leading-relaxed text-paper"> | |
| - | {e.href ? ( | |
| - | <Link | |
| - | href={e.href} | |
| - | className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper" | |
| - | > | |
| - | {e.title} | |
| - | </Link> | |
| - | ) : ( | |
| - | e.title | |
| - | )} | |
| - | </p> | |
| - | <div className="mt-2"> | |
| - | <SourceLink url={e.sourceUrl} /> | |
| - | </div> | |
| - | </article> | |
| - | ); | |
| - | })} | |
| - | </div> | |
| + | {m.plain} | |
| + | </span> | |
| + | ))} | |
| + | </div> | |
| + | </Link> | |
| + | {/* Inline expanded view for the selected day */} | |
| + | {isSelected && dayEvents.length > 0 && ( | |
| + | <div className="mt-2 space-y-2 pl-3"> | |
| + | {dayEvents.map((e) => { | |
| + | const k = kindByValue.get(e.kind); | |
| + | return ( | |
| + | <article | |
| + | key={e.id} | |
| + | className="rounded-[3px] border border-line bg-surface p-3" | |
| + | > | |
| + | <div className="flex flex-wrap items-center gap-2"> | |
| + | {k ? ( | |
| + | <span | |
| + | className="pill !py-0.5" | |
| + | style={{ | |
| + | color: k.color, | |
| + | borderColor: `color-mix(in srgb, ${k.color} 45%, transparent)`, | |
| + | }} | |
| + | > | |
| + | {k.plain} | |
| + | </span> | |
| + | ) : ( | |
| + | <span className="pill !py-0.5 text-ash">{e.kind}</span> | |
| + | )} | |
| + | <span className="label text-ash-2">{e.refType}</span> | |
| + | </div> | |
| + | <p className="mt-2 break-words font-mono text-[12px] leading-relaxed text-paper"> | |
| + | {e.href ? ( | |
| + | <Link | |
| + | href={e.href} | |
| + | className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper" | |
| + | > | |
| + | {e.title} | |
| + | </Link> | |
| + | ) : ( | |
| + | e.title | |
| + | )} | |
| + | </p> | |
| + | <div className="mt-2"> | |
| + | <SourceLink url={e.sourceUrl} /> | |
| + | </div> | |
| + | </article> | |
| + | ); | |
| + | })} | |
| + | </div> | |
| + | )} | |
| </section> | ||
| ); | ||
| })} |
| @@ -3,7 +3,7 @@ import Link from "next/link"; | ||
| import { Card, PageHeader } from "@/components/ui"; | ||
| import { getCategories } from "@/lib/feed"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export const metadata: Metadata = { | ||
| title: "Topics", |
| @@ -10,7 +10,7 @@ export const metadata: Metadata = { | ||
| "Executive Orders, proposed and final rules, and notices from the Federal Register. Filterable by type.", | ||
| }; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| const TYPE_LABEL: Record<string, string> = { | ||
| eo: "Executive Order", |
| @@ -13,9 +13,10 @@ import { | ||
| type MemberVote, | ||
| type MemberAmendment, | ||
| } from "@/lib/feed"; | ||
| + | import { MemberVoteYear } from "@/components/member-vote-year"; | |
| import { formatDate } from "@/lib/format"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export function generateMetadata({ | ||
| params, | ||
| @@ -184,7 +185,7 @@ export default function MemberDetailPage({ | ||
| </Card> | ||
| ) : ( | ||
| <> | ||
| - | {/* Per-year count summary (informational, not jump links) */} | |
| + | {/* Per-year count chips - quick summary at a glance. */} | |
| <ul className="mb-5 flex flex-wrap gap-2"> | ||
| {member.votesByYear.map((yg) => ( | ||
| <li | ||
| @@ -197,28 +198,17 @@ export default function MemberDetailPage({ | ||
| ))} | ||
| </ul> | ||
| + | {/* Each year panel lazy-loads its votes from the API on first open. | |
| + | This keeps the initial SSR payload small for long-serving members. */} | |
| <div className="space-y-3"> | ||
| {member.votesByYear.map((yg, i) => ( | ||
| - | <Card key={yg.year ?? "undated"} className="overflow-hidden"> | |
| - | {/* overflow-hidden on details + the content div ensures the | |
| - | DataTable's desktop table can never escape the card and | |
| - | push the page wider than the viewport. */} | |
| - | <details open={i === 0} className="overflow-hidden"> | |
| - | <summary | |
| - | className="focus-ring flex cursor-pointer items-center justify-between gap-3 px-4 py-3.5" | |
| - | > | |
| - | <span className="font-mono text-[13px] text-paper"> | |
| - | {yg.year ?? "Undated"} | |
| - | </span> | |
| - | <span className="label text-ash-2"> | |
| - | {yg.count} vote{yg.count === 1 ? "" : "s"} | |
| - | </span> | |
| - | </summary> | |
| - | <div className="overflow-hidden border-t border-line"> | |
| - | <MemberVotesTable votes={yg.votes} dense /> | |
| - | </div> | |
| - | </details> | |
| - | </Card> | |
| + | <MemberVoteYear | |
| + | key={yg.year ?? "undated"} | |
| + | bioguide={member.bioguide} | |
| + | year={yg.year} | |
| + | count={yg.count} | |
| + | defaultOpen={i === 0} | |
| + | /> | |
| ))} | ||
| </div> | ||
| </> |
| @@ -1,13 +1,10 @@ | ||
| import type { Metadata } from "next"; | ||
| - | import Link from "next/link"; | |
| - | import { Card, PageHeader } from "@/components/ui"; | |
| - | import { SourceLink } from "@/components/source-link"; | |
| - | import { PartyTag } from "@/components/party-tag"; | |
| import { | ||
| - | getMembers, | |
| + | getMembersDefault, | |
| + | getMemberCounts, | |
| getMemberStates, | ||
| - | type MemberSummary, | |
| } from "@/lib/feed"; | ||
| + | import { Card, PageHeader } from "@/components/ui"; | |
| import { MembersBrowser } from "@/components/members-browser"; | ||
| export const metadata: Metadata = { | ||
| @@ -16,10 +13,17 @@ export const metadata: Metadata = { | ||
| "Browse the U.S. House and Senate. Search by name, state, chamber, or party. Each member links to their full record.", | ||
| }; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export default function MembersPage() { | ||
| - | const all = getMembers(); | |
| + | // Use cheap COUNT queries for the summary cards - no need to serialize | |
| + | // 12.7k member objects just to count them. | |
| + | const counts = getMemberCounts(); | |
| + | // Only pass the default-visible subset (current Congress + members with | |
| + | // photos) to the client - keeps the SSR HTML payload small. Historical | |
| + | // members without photos are loaded lazily when the toggle is clicked. | |
| + | const defaultMembers = getMembersDefault(); | |
| + | const hiddenCount = counts.total - defaultMembers.length; | |
| const states = getMemberStates(); | ||
| return ( | ||
| @@ -33,19 +37,17 @@ export default function MembersPage() { | ||
| <div className="mt-8"> | ||
| {/* Summary by chamber, ordered by count then alphabetical (not by party). */} | ||
| <div className="grid gap-4 sm:grid-cols-3"> | ||
| - | <SummaryCard label="Total" count={all.length} /> | |
| - | <SummaryCard | |
| - | label="Senate" | |
| - | count={all.filter((m) => m.chamber === "senate").length} | |
| - | /> | |
| - | <SummaryCard | |
| - | label="House" | |
| - | count={all.filter((m) => m.chamber === "house").length} | |
| - | /> | |
| + | <SummaryCard label="Total" count={counts.total} /> | |
| + | <SummaryCard label="Senate" count={counts.senate} /> | |
| + | <SummaryCard label="House" count={counts.house} /> | |
| </div> | ||
| <div className="mt-10"> | ||
| - | <MembersBrowser seed={all} states={states} /> | |
| + | <MembersBrowser | |
| + | seed={defaultMembers} | |
| + | states={states} | |
| + | hiddenCount={hiddenCount > 0 ? hiddenCount : 0} | |
| + | /> | |
| </div> | ||
| </div> | ||
| </div> |
| @@ -7,7 +7,7 @@ import { SourceLink } from "@/components/source-link"; | ||
| import { ChangesFeed } from "@/components/changes-feed"; | ||
| import { | ||
| getFeedItems, | ||
| - | getAwards, | |
| + | getAwardsSummary, | |
| getChangeEvents, | ||
| getCategories, | ||
| type FeedItem, | ||
| @@ -20,8 +20,10 @@ export const metadata: Metadata = { | ||
| "A live, stamped ledger of official US government actions - bills, votes, and dollars. Every fact sourced.", | ||
| }; | ||
| - | // Always show the freshest ingest rows. | |
| - | export const dynamic = "force-dynamic"; | |
| + | // Revalidate every 5 minutes - staleness is acceptable for this read-only | |
| + | // public data site. The in-process DB cache in lib/feed.ts also avoids | |
| + | // redundant SQLite queries within the revalidation window. | |
| + | export const revalidate = 300; | |
| const ACTION_LABEL: Record<FeedItem["kind"], string> = { | ||
| bill: "Bills", | ||
| @@ -42,13 +44,15 @@ const FEED_RENDER_LIMIT = 150; | ||
| export default async function RegisterPage() { | ||
| const items = getFeedItems(); | ||
| const shown = items.slice(0, FEED_RENDER_LIMIT); | ||
| - | const awards = getAwards(); | |
| + | // Use cheap aggregate instead of fetching all award rows just for total/count. | |
| + | const awardStats = getAwardsSummary(); | |
| const changes = getChangeEvents(12); | ||
| const topTopics = getCategories() | ||
| .filter((c) => c.total > 0) | ||
| .sort((a, b) => b.total - a.total) | ||
| .slice(0, 8); | ||
| - | const weekAwardsTotal = awards.reduce((n, a) => n + a.amount, 0); | |
| + | // Compute byKind once - it's used twice in the JSX below. | |
| + | const kindBreakdown = byKind(items); | |
| return ( | ||
| <> | ||
| @@ -78,8 +82,8 @@ export default async function RegisterPage() { | ||
| <Card className="p-5"> | ||
| <p className="label mb-4">Composition by action type</p> | ||
| <ul className="space-y-2.5"> | ||
| - | {byKind(items).map((a) => { | |
| - | const max = Math.max(1, ...byKind(items).map((x) => x.count)); | |
| + | {kindBreakdown.map((a) => { | |
| + | const max = Math.max(1, ...kindBreakdown.map((x) => x.count)); | |
| return ( | ||
| <li key={a.kind} className="font-mono text-[12px]"> | ||
| <div className="flex items-center justify-between"> | ||
| @@ -107,10 +111,10 @@ export default async function RegisterPage() { | ||
| <Card className="p-5"> | ||
| <p className="label mb-4">Awards, on record</p> | ||
| <p className="display text-3xl text-paper"> | ||
| - | {formatUsd(weekAwardsTotal, { compact: true })} | |
| + | {formatUsd(awardStats.total, { compact: true })} | |
| </p> | ||
| <p className="prose-lead mt-2 text-ash"> | ||
| - | {awards.length} federal awards from USAspending.gov, summed across | |
| + | {awardStats.count} federal awards from USAspending.gov, summed across | |
| the record. Figures only - no characterization. | ||
| </p> | ||
| <Link |
| @@ -4,7 +4,7 @@ import { PageHeader } from "@/components/ui"; | ||
| import { getMeetings, getMeetingCommittees } from "@/lib/feed"; | ||
| import { MeetingsBrowser } from "@/components/meetings-browser"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export const metadata: Metadata = { | ||
| title: "Congressional Schedule", |
| @@ -12,7 +12,7 @@ export const metadata: Metadata = { | ||
| "Look up a bill, member, agency, or topic across the official record.", | ||
| }; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export default function SearchPage({ | ||
| searchParams, |
| @@ -11,7 +11,7 @@ export const metadata: Metadata = { | ||
| "Federal awards, OMB apportionments, and fiscal totals from USAspending.gov, .max.gov, and Treasury. Every figure sourced.", | ||
| }; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| /** Impound status → muted semantic tone (released = sage, delayed = hold, | ||
| * withheld = clay). Never vivid; a withheld status is recorded as fact. */ |
| @@ -8,7 +8,7 @@ import { StatusPill, classifyStatus } from "@/components/status-pill"; | ||
| import { getStateDetail } from "@/lib/feed"; | ||
| import { formatDate } from "@/lib/format"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export function generateMetadata({ | ||
| params, |
| @@ -12,7 +12,7 @@ export const metadata: Metadata = { | ||
| "Coverage for the 50 states, DC, and U.S. territories. State-legislative coverage is rolling out; gaps are recorded, not hidden. Open a state for its delegation and sponsored bills.", | ||
| }; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| /** | ||
| * Coverage framing. Federal data (congress.gov delegation + bill sponsorships) |
| @@ -12,7 +12,7 @@ import { MemberAvatar } from "@/components/member-avatar"; | ||
| import { getVoteById, type VotePosition } from "@/lib/feed"; | ||
| import { formatDate } from "@/lib/format"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export function generateMetadata({ | ||
| params, |
| @@ -4,7 +4,7 @@ import { Card, PageHeader, SectionHeader } from "@/components/ui"; | ||
| import { DataTable, type Column, type Row } from "@/components/data-table"; | ||
| import { SourceLink } from "@/components/source-link"; | ||
| import { StatusPill, classifyStatus } from "@/components/status-pill"; | ||
| - | import { getVotes } from "@/lib/feed"; | |
| + | import { getVotes, getVoteCount } from "@/lib/feed"; | |
| import { formatDate } from "@/lib/format"; | ||
| export const metadata: Metadata = { | ||
| @@ -13,11 +13,13 @@ export const metadata: Metadata = { | ||
| "Recorded roll-call votes of the U.S. House and Senate. Every vote links to its official record.", | ||
| }; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | export const revalidate = 300; | |
| export default function VotesPage() { | ||
| - | const votes = getVotes(); | |
| - | const shown = votes.slice(0, 100); // render cap; full count shown in eyebrow | |
| + | // Fetch only what we render (100 rows) + a cheap COUNT(*) for the eyebrow. | |
| + | const votes = getVotes(100, 0); | |
| + | const totalVotes = getVoteCount(); | |
| + | const shown = votes; // already limited to 100 | |
| return ( | ||
| <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | ||
| @@ -29,7 +31,7 @@ export default function VotesPage() { | ||
| <div className="mt-8"> | ||
| <SectionHeader | ||
| - | eyebrow={`${votes.length} votes`} | |
| + | eyebrow={`${totalVotes.toLocaleString()} votes`} | |
| 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." | ||
| /> |
| @@ -0,0 +1,143 @@ | ||
| + | "use client"; | |
| + | ||
| + | /** | |
| + | * Lazy-loading accordion panel for one year's worth of a member's recorded | |
| + | * votes. The votes are NOT shipped in the initial SSR HTML - they are fetched | |
| + | * from /api/members/[id]/votes?year=YYYY only when the <details> panel is | |
| + | * first opened (or immediately on mount when defaultOpen=true). | |
| + | * | |
| + | * This keeps the member-detail page payload small even for long-serving | |
| + | * members who have 10 000+ recorded positions. | |
| + | */ | |
| + | ||
| + | import { useState, useEffect, useRef, useCallback } from "react"; | |
| + | import Link from "next/link"; | |
| + | import { Card } from "@/components/ui"; | |
| + | import { DataTable, type Column, type Row } from "@/components/data-table"; | |
| + | import { PositionTag } from "@/components/position-tag"; | |
| + | import { formatDate } from "@/lib/format"; | |
| + | import type { MemberVote } from "@/lib/feed"; | |
| + | ||
| + | interface Props { | |
| + | bioguide: string; | |
| + | year: number | null; | |
| + | count: number; | |
| + | defaultOpen?: boolean; | |
| + | } | |
| + | ||
| + | export function MemberVoteYear({ | |
| + | bioguide, | |
| + | year, | |
| + | count, | |
| + | defaultOpen = false, | |
| + | }: Props) { | |
| + | // Controlled open state mirrors the browser's native toggle. | |
| + | const [open, setOpen] = useState(defaultOpen); | |
| + | const [votes, setVotes] = useState<MemberVote[] | null>(null); | |
| + | const [loading, setLoading] = useState(false); | |
| + | const [error, setError] = useState(false); | |
| + | // Guard so the fetch only fires once per panel lifetime. | |
| + | const fetchedRef = useRef(false); | |
| + | ||
| + | const doFetch = useCallback(() => { | |
| + | if (fetchedRef.current) return; | |
| + | fetchedRef.current = true; | |
| + | setLoading(true); | |
| + | const yearParam = year == null ? "null" : year.toString(); | |
| + | fetch(`/api/members/${bioguide}/votes?year=${yearParam}`) | |
| + | .then((r) => { | |
| + | if (!r.ok) throw new Error("fetch failed"); | |
| + | return r.json() as Promise<{ votes: MemberVote[] }>; | |
| + | }) | |
| + | .then((data) => { | |
| + | setVotes(data.votes); | |
| + | setLoading(false); | |
| + | }) | |
| + | .catch(() => { | |
| + | fetchedRef.current = false; // allow retry on next open | |
| + | setError(true); | |
| + | setLoading(false); | |
| + | }); | |
| + | }, [bioguide, year]); | |
| + | ||
| + | // Auto-fetch when this panel starts open (most-recent-year default). | |
| + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| + | useEffect(() => { if (defaultOpen) doFetch(); }, []); | |
| + | ||
| + | const handleToggle = useCallback( | |
| + | (e: React.SyntheticEvent<HTMLDetailsElement>) => { | |
| + | const nowOpen = e.currentTarget.open; | |
| + | setOpen(nowOpen); | |
| + | if (nowOpen) doFetch(); | |
| + | }, | |
| + | [doFetch] | |
| + | ); | |
| + | ||
| + | return ( | |
| + | <Card className="overflow-hidden"> | |
| + | {/* open={open} is a controlled prop - onToggle keeps state in sync so | |
| + | React never fights the browser's native toggle. */} | |
| + | <details open={open} className="overflow-hidden" onToggle={handleToggle}> | |
| + | <summary className="focus-ring flex cursor-pointer items-center justify-between gap-3 px-4 py-3.5"> | |
| + | <span className="font-mono text-[13px] text-paper"> | |
| + | {year ?? "Undated"} | |
| + | </span> | |
| + | <span className="label text-ash-2"> | |
| + | {count} vote{count === 1 ? "" : "s"} | |
| + | </span> | |
| + | </summary> | |
| + | ||
| + | <div className="overflow-hidden border-t border-line"> | |
| + | {loading && ( | |
| + | <p className="px-4 py-3 font-mono text-[13px] text-ash-2"> | |
| + | Loading… | |
| + | </p> | |
| + | )} | |
| + | {error && ( | |
| + | <p className="px-4 py-3 font-mono text-[13px] text-ash-2"> | |
| + | Failed to load - open again to retry. | |
| + | </p> | |
| + | )} | |
| + | {votes && <VoteTable votes={votes} />} | |
| + | </div> | |
| + | </details> | |
| + | </Card> | |
| + | ); | |
| + | } | |
| + | ||
| + | /** Compact vote table - mirrors the dense MemberVotesTable in the detail page. */ | |
| + | function VoteTable({ votes }: { votes: MemberVote[] }) { | |
| + | const columns: Column[] = [ | |
| + | { key: "date", header: "Date" }, | |
| + | { key: "result", header: "Question", cardTitle: true }, | |
| + | { key: "voted", header: "Voted" }, | |
| + | ]; | |
| + | const rows: Row[] = votes.map((v) => ({ | |
| + | key: v.voteId, | |
| + | cells: { | |
| + | date: ( | |
| + | <span className="tabular-nums text-ash">{formatDate(v.date)}</span> | |
| + | ), | |
| + | result: ( | |
| + | <span className="text-paper"> | |
| + | <Link | |
| + | href={v.voteHref} | |
| + | className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper" | |
| + | > | |
| + | {v.question || v.result} | |
| + | </Link> | |
| + | <span className="block text-ash-2">{v.result}</span> | |
| + | </span> | |
| + | ), | |
| + | voted: <PositionTag position={v.position} />, | |
| + | }, | |
| + | })); | |
| + | return ( | |
| + | <DataTable | |
| + | caption="This member's recorded votes: date, question, result, position." | |
| + | cardLabel="Recorded votes" | |
| + | columns={columns} | |
| + | rows={rows} | |
| + | /> | |
| + | ); | |
| + | } |
| @@ -1,6 +1,6 @@ | ||
| "use client"; | ||
| - | import { useMemo, useState } from "react"; | |
| + | import { useMemo, useState, useCallback } from "react"; | |
| import Link from "next/link"; | ||
| import { Card } from "@/components/ui"; | ||
| import { PartyTag } from "@/components/party-tag"; | ||
| @@ -16,31 +16,69 @@ import type { MemberSummary } from "@/lib/feed"; | ||
| * disclosure toggle. All filtering is local over the server-seeded list. | ||
| * Party chips render in desaturated, equal-weight tones; the list stays | ||
| * alphabetical within each tier (never grouped by party - neutrality §Viz). | ||
| + | * | |
| + | * `seed` contains only the default-visible members (current + those with a | |
| + | * photo). When the user clicks "Show all historical", the full list is fetched | |
| + | * from /api/members once and merged in. This keeps the SSR payload small. | |
| */ | ||
| export function MembersBrowser({ | ||
| seed, | ||
| states, | ||
| + | hiddenCount = 0, | |
| }: { | ||
| seed: MemberSummary[]; | ||
| states: string[]; | ||
| + | /** Number of historical members not included in seed (no photo). */ | |
| + | hiddenCount?: number; | |
| }) { | ||
| const [q, setQ] = useState(""); | ||
| const [state, setState] = useState(""); | ||
| const [chamber, setChamber] = useState(""); | ||
| const [party, setParty] = useState(""); | ||
| const [showAllHistorical, setShowAllHistorical] = useState(false); | ||
| + | const [allMembers, setAllMembers] = useState<MemberSummary[] | null>(null); | |
| + | const [loadingAll, setLoadingAll] = useState(false); | |
| - | // Count historical members with no photo - shown behind the toggle. | |
| - | const hiddenCount = useMemo( | |
| - | () => seed.filter((m) => !m.isCurrent && !m.photoUrl).length, | |
| - | [seed] | |
| - | ); | |
| + | // Fetch the full member list once when the toggle is first turned on. | |
| + | const handleShowAll = useCallback(async () => { | |
| + | if (!showAllHistorical) { | |
| + | if (!allMembers && hiddenCount > 0) { | |
| + | setLoadingAll(true); | |
| + | try { | |
| + | const res = await fetch("/api/members?limit=200"); | |
| + | const first = await res.json(); | |
| + | // /api/members paginates at 200 - for historical roster we need all. | |
| + | // Fetch in chunks until done. | |
| + | const items: MemberSummary[] = [...first.items]; | |
| + | let offset = first.items.length; | |
| + | while (items.length < first.total) { | |
| + | const page = await fetch(`/api/members?limit=200&offset=${offset}`); | |
| + | const data = await page.json(); | |
| + | items.push(...data.items); | |
| + | offset += data.items.length; | |
| + | if (!data.items.length) break; | |
| + | } | |
| + | setAllMembers(items); | |
| + | } catch { | |
| + | // Fall back gracefully: just show seed. | |
| + | } finally { | |
| + | setLoadingAll(false); | |
| + | } | |
| + | } | |
| + | setShowAllHistorical(true); | |
| + | } else { | |
| + | setShowAllHistorical(false); | |
| + | } | |
| + | }, [showAllHistorical, allMembers, hiddenCount]); | |
| + | ||
| + | // The active member pool: full list when toggle is on, seed otherwise. | |
| + | const pool = showAllHistorical && allMembers ? allMembers : seed; | |
| const filtered = useMemo(() => { | ||
| const query = q.trim().toLowerCase(); | ||
| const anyFilterActive = !!(q || state || chamber || party); | ||
| - | return seed | |
| + | return pool | |
| .filter((m) => { | ||
| // When no search/filter is active, hide historical-without-photo | ||
| // unless the toggle is on. When any filter is active, show everything | ||
| @@ -72,7 +110,7 @@ export function MembersBrowser({ | ||
| // Within each tier: alphabetical by name | ||
| return a.name.localeCompare(b.name); | ||
| }); | ||
| - | }, [seed, q, state, chamber, party, showAllHistorical]); | |
| + | }, [pool, q, state, chamber, party, showAllHistorical]); | |
| const anyFilterActive = !!(q || state || chamber || party); | ||
| @@ -142,10 +180,13 @@ export function MembersBrowser({ | ||
| {!anyFilterActive && hiddenCount > 0 && ( | ||
| <button | ||
| type="button" | ||
| - | onClick={() => setShowAllHistorical((v) => !v)} | |
| - | className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper" | |
| + | onClick={handleShowAll} | |
| + | disabled={loadingAll} | |
| + | className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper disabled:opacity-50" | |
| > | ||
| - | {showAllHistorical | |
| + | {loadingAll | |
| + | ? "Loading…" | |
| + | : showAllHistorical | |
| ? "Hide historical members without a photo" | ||
| : `Show all ${hiddenCount.toLocaleString()} historical members`} | ||
| </button> |
| @@ -0,0 +1,43 @@ | ||
| + | # Docket - UX/QoL Redesign Spec (2026-06-27) | |
| + | ||
| + | "Back to the drawing board" for usability. Audience = an **elderly citizen** (low tech-literacy, low vision) AND a **high-school/college student citing Docket as an educational source**. Decisions below are LOCKED by Zion. Keep strict neutrality (no lean, no adjectives) and keep all data/sourcing. | |
| + | ||
| + | ## Decisions (locked) | |
| + | 1. **Default look = legible/light "Reference" theme.** The dark "forensic terminal" becomes an OPTIONAL mode, not the default. | |
| + | 2. **"Cite this" = first-class feature** (APA/MLA/Chicago + permalink + accessed-date) on every record + page. | |
| + | 3. Search/filter on every list page. De-clutter IA. Clear labels. Performance already fixed. | |
| + | ||
| + | ## Theme system (3 modes; default changes) | |
| + | Mode toggle stays, but the DEFAULT becomes **Reference**: | |
| + | - **Reference (NEW DEFAULT - light):** background `#F8FAFC`, surface `#FFFFFF`, text `#0F172A`/`#020617`, muted `#475569`, border `#E2E8F0`. **Accent = brass `#B07F25`** (the brand thread; brass darkened for AA on light - NEVER use blue/red as accent, neutrality). Body font **Atkinson Hyperlegible** (engineered for low vision). Headings **Fraunces** (keep, scholarly/citable). Mono **JetBrains Mono ONLY for true data tokens** (vote tallies, bill numbers, dates) - not for prose. Body ≥18px, line-height 1.6, generous whitespace, single-column, max-w ~720-860px for reading. | |
| + | - **Dark (forensic - now optional):** the existing dark brand (ink/brass/mono). Preserved for power users. | |
| + | - **Plain (high-contrast):** existing max-contrast mode (system font, ≥20px) for max accessibility. | |
| + | All three: WCAG AA min (AAA target), ≥44px tap targets, visible focus rings, underlined links, no hover-only, prefers-reduced-motion respected. | |
| + | ||
| + | ## IA / declutter (each page does ONE job) | |
| + | - **Register (home):** the ranked feed, period. Remove the crowding side cards (composition chart, awards card, topic chips, changes feed) from the main view - move them to a compact, collapsed "More" area or their own pages. Lead with a short plain-language explainer + the feed. One primary action. | |
| + | - **Every list page (Register, Bills, Votes, Members, Spending, Executive, States):** a prominent labeled **search/filter bar at the top** (mobile + desktop) - search box ≥44px tall, plus the relevant dropdown filters. Results as the existing responsive cards. | |
| + | - **Detail pages (bill, vote, member):** clear heading → key facts → source → "Cite this". No crowding. | |
| + | - Consistent header on every page: wordmark, a clear page-nav (hamburger on mobile / simple bar on desktop) with active-page indicator, the mode toggle as a small distinct control, and a global search entry. | |
| + | ||
| + | ## Search/filter (item #1) | |
| + | - **/members:** search by name; filter by state, chamber, party, current-vs-historical. (Already defaults current-first.) | |
| + | - **/bills:** keep existing filters + ensure the search box is prominent. | |
| + | - **/votes:** add search/filter (by chamber, result, date/keyword). | |
| + | - Reuse a shared SearchFilterBar component. Server-side filtering where possible (perf), client-side for already-loaded sets. | |
| + | ||
| + | ## "Cite this" (item #2 - first-class) | |
| + | - A `CiteThis` component on every record (bill, vote, member, award) and every page. | |
| + | - Generates a ready citation in **APA, MLA, and Chicago**, plus a **permalink** and the **accessed date** (today). Example (APA): *Docket. (2026). H.R. 22 - [title] [Data set]. U.S. Congress. Retrieved Month D, YYYY, from https://usdocket.org/bill/...* - but ALWAYS point the underlying source to the official .gov record too (neutrality rule #1). | |
| + | - Copy-to-clipboard buttons. Neutral, factual, no adjectives. | |
| + | - This is the differentiator for the student/educator audience. | |
| + | ||
| + | ## Labels / clarity (the "51" problem) | |
| + | - **States page:** show **"50 states"** as the count. **DC and the 5 territories are their own clearly-labeled rows/section - DC is a federal district, NOT a state; never produce a "51 states" number.** Coverage framed plainly. | |
| + | - Plain dates ("June 26, 2026"), plain status words, expand acronyms on first use, ≤8th-grade reading level for any explanatory copy (docs/ACCESSIBILITY.md). | |
| + | ||
| + | ## Out of scope / keep | |
| + | - Keep all data, sources, neutrality gate, the 3-mode toggle mechanism. Keep the brass brand thread. Don't regress the responsive cards or the perf fixes (PERF_NOTES.md). | |
| + | ||
| + | ## Verify | |
| + | Build + run the mobile QA crawl (`_ops/rebuild-qa.sh`) → must stay QA CLEAN (no overflow/hydration). Then check the new default renders light + legible, search works on list pages, Cite this works, states labeled correctly. Screenshots at 390px + desktop. |
| @@ -8,7 +8,23 @@ | ||
| * | ||
| * Server-only. Imports better-sqlite3 (kept external via next.config.js). | ||
| */ | ||
| - | import { safeAll, safeGet } from "@/lib/db"; | |
| + | import { safeAll, safeGet, safeCount } from "@/lib/db"; | |
| + | ||
| + | /* ------------------------------------------------------------------ */ | |
| + | /* Simple in-process TTL cache (server-side only, per-request for RSC) */ | |
| + | /* ------------------------------------------------------------------ */ | |
| + | type CacheEntry<T> = { value: T; expiry: number }; | |
| + | const _cache = new Map<string, CacheEntry<unknown>>(); | |
| + | const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes | |
| + | ||
| + | function cached<T>(key: string, fn: () => T, ttl = CACHE_TTL_MS): T { | |
| + | const now = Date.now(); | |
| + | const entry = _cache.get(key) as CacheEntry<T> | undefined; | |
| + | if (entry && entry.expiry > now) return entry.value; | |
| + | const value = fn(); | |
| + | _cache.set(key, { value, expiry: now + ttl }); | |
| + | return value; | |
| + | } | |
| import { formatUsd } from "@/lib/format"; | ||
| import type { PartyCode } from "@/lib/neutrality"; | ||
| @@ -166,10 +182,12 @@ export type MemberVote = { | ||
| * view. `year` is null when the underlying vote row is undated; such a | ||
| * bucket sorts last and is labelled "Undated" by the UI. | ||
| */ | ||
| + | /** Year-summary row for the member year-by-year panel header. | |
| + | * Votes are NOT embedded here - they are fetched lazily by the | |
| + | * MemberVoteYear client component when a panel is first opened. */ | |
| export type MemberYearGroup = { | ||
| year: number | null; | ||
| count: number; | ||
| - | votes: MemberVote[]; | |
| }; | ||
| export type MemberSponsorship = { | ||
| @@ -341,43 +359,68 @@ function dateKey(iso: string): number { | ||
| /* FEED - mixed, ranked. Pulls each source defensively. */ | ||
| /* ------------------------------------------------------------------ */ | ||
| - | export function getFeedItems(): FeedItem[] { | |
| - | const items: FeedItem[] = []; | |
| + | /** | |
| + | * Get the ranked, diversified feed. Each source is capped at `perSource` | |
| + | * rows in SQL so we never pull tens-of-thousands of rows just to slice 150. | |
| + | * Results are cached for CACHE_TTL_MS (5 min) - staleness is acceptable for | |
| + | * this public read-only data site. | |
| + | */ | |
| + | export function getFeedItems(perSource = 400): FeedItem[] { | |
| + | return cached(`feed:${perSource}`, () => { | |
| + | const items: FeedItem[] = []; | |
| + | ||
| + | // Awards (USAspending) - top by dollar amount. | |
| + | items.push( | |
| + | ...safeAll<FeedItem>( | |
| + | `SELECT id, agency, recipient, amount, action_date, program, source_url | |
| + | FROM awards | |
| + | ORDER BY amount DESC | |
| + | LIMIT :lim`, | |
| + | { lim: perSource }, | |
| + | (r) => toFeedAward(r) | |
| + | ) | |
| + | ); | |
| - | // Awards (USAspending). | |
| - | items.push( | |
| - | ...safeAll<FeedItem>( | |
| - | `SELECT id, agency, recipient, amount, action_date, program, source_url | |
| - | FROM awards | |
| - | ORDER BY amount DESC`, | |
| - | {}, | |
| - | (r) => toFeedAward(r) | |
| - | ) | |
| - | ); | |
| + | // Votes (House/Senate roll calls) - most recent. | |
| + | items.push( | |
| + | ...safeAll<FeedItem>( | |
| + | `SELECT id, bill_id, chamber, date, yea, nay, present, result, source_url | |
| + | FROM votes | |
| + | ORDER BY date DESC | |
| + | LIMIT :lim`, | |
| + | { lim: perSource }, | |
| + | (r) => toFeedVote(r) | |
| + | ) | |
| + | ); | |
| - | // Votes (House/Senate roll calls). | |
| - | items.push( | |
| - | ...safeAll<FeedItem>( | |
| - | `SELECT id, bill_id, chamber, date, yea, nay, present, result, source_url | |
| - | FROM votes | |
| - | ORDER BY date DESC`, | |
| - | {}, | |
| - | (r) => toFeedVote(r) | |
| - | ) | |
| - | ); | |
| + | // Executive actions (Federal Register) - most recent. | |
| + | items.push( | |
| + | ...safeAll<FeedItem>( | |
| + | `SELECT id, type, title, agency, fr_doc_number, date, source_url | |
| + | FROM executive_actions | |
| + | ORDER BY date DESC | |
| + | LIMIT :lim`, | |
| + | { lim: perSource }, | |
| + | (r) => toFeedExecutive(r) | |
| + | ) | |
| + | ); | |
| - | // Executive actions (Federal Register). | |
| - | items.push( | |
| - | ...safeAll<FeedItem>( | |
| - | `SELECT id, type, title, agency, fr_doc_number, date, source_url | |
| - | FROM executive_actions | |
| - | ORDER BY date DESC`, | |
| - | {}, | |
| - | (r) => toFeedExecutive(r) | |
| - | ) | |
| - | ); | |
| + | return diversifyByKind(items.sort(rankItems)); | |
| + | }); | |
| + | } | |
| - | return diversifyByKind(items.sort(rankItems)); | |
| + | /** Count totals for the "Composition by kind" panel without fetching all rows. */ | |
| + | export function getFeedKindCounts(): Map<string, number> { | |
| + | return cached("feed:kindcounts", () => { | |
| + | const map = new Map<string, number>(); | |
| + | const awardsCount = safeCount("awards"); | |
| + | const votesCount = safeCount("votes"); | |
| + | const execCount = safeCount("executive_actions"); | |
| + | if (awardsCount) map.set("award", awardsCount); | |
| + | if (votesCount) map.set("vote", votesCount); | |
| + | if (execCount) map.set("executive", execCount); | |
| + | return map; | |
| + | }); | |
| } | ||
| /** | ||
| @@ -529,6 +572,16 @@ function execTypeLabel(t: string): string { | ||
| /* AWARDS / FISCAL / APPORTIONMENTS */ | ||
| /* ------------------------------------------------------------------ */ | ||
| + | /** Cheap aggregate stats for the register's Awards card (avoids full-table scan). */ | |
| + | export function getAwardsSummary(): { total: number; count: number } { | |
| + | return cached("awards:summary", () => { | |
| + | const row = safeGet<{ total: number; count: number }>( | |
| + | `SELECT COALESCE(SUM(amount), 0) AS total, COUNT(*) AS count FROM awards` | |
| + | ); | |
| + | return { total: row?.total ?? 0, count: row?.count ?? 0 }; | |
| + | }); | |
| + | } | |
| + | ||
| export function getAwards(): Award[] { | ||
| return safeAll<Award>( | ||
| `SELECT id, agency, recipient, amount, action_date, program, source_url | ||
| @@ -596,12 +649,37 @@ export function getApportionments(): Apportionment[] { | ||
| /* VOTES */ | ||
| /* ------------------------------------------------------------------ */ | ||
| - | export function getVotes(): Vote[] { | |
| + | /** Total number of votes in the DB, optionally filtered by chamber (cheap COUNT). */ | |
| + | export function getVoteCount(chamber?: string): number { | |
| + | if (chamber) { | |
| + | const row = safeGet<{ c: number }>( | |
| + | `SELECT COUNT(*) AS c FROM votes WHERE LOWER(chamber) = :ch`, | |
| + | { ch: chamber.toLowerCase() } | |
| + | ); | |
| + | return row?.c ?? 0; | |
| + | } | |
| + | return safeCount("votes"); | |
| + | } | |
| + | ||
| + | /** Paginated votes list - only fetches what the page renders. */ | |
| + | export function getVotes(limit = 100, offset = 0, chamber?: string): Vote[] { | |
| + | if (chamber) { | |
| + | return safeAll<Vote>( | |
| + | `SELECT id, bill_id, chamber, date, yea, nay, present, result, source_url | |
| + | FROM votes | |
| + | WHERE LOWER(chamber) = :ch | |
| + | ORDER BY date DESC | |
| + | LIMIT :limit OFFSET :offset`, | |
| + | { ch: chamber.toLowerCase(), limit, offset }, | |
| + | (r) => toVote(r) | |
| + | ); | |
| + | } | |
| return safeAll<Vote>( | ||
| `SELECT id, bill_id, chamber, date, yea, nay, present, result, source_url | ||
| FROM votes | ||
| - | ORDER BY date DESC`, | |
| - | {}, | |
| + | ORDER BY date DESC | |
| + | LIMIT :limit OFFSET :offset`, | |
| + | { limit, offset }, | |
| (r) => toVote(r) | ||
| ); | ||
| } | ||
| @@ -733,13 +811,71 @@ export type BillFilter = { | ||
| sort?: "updated" | "action"; | ||
| }; | ||
| - | export function getBills(filter: BillFilter = {}): BillListItem[] { | |
| - | const items = safeAll<BillListItem>( | |
| + | /** | |
| + | * Build WHERE clause + params for bill filters, pushing all filtering into SQL. | |
| + | */ | |
| + | function buildBillsWhere(filter: BillFilter): { where: string; params: Record<string, unknown> } { | |
| + | const clauses: string[] = []; | |
| + | const params: Record<string, unknown> = {}; | |
| + | ||
| + | if (filter.q) { | |
| + | const like = `%${filter.q.trim().replace(/[%_\\]/g, (m) => "\\" + m)}%`; | |
| + | clauses.push( | |
| + | "(b.number LIKE :q ESCAPE '\\' OR b.title LIKE :q ESCAPE '\\' OR b.policy_area LIKE :q ESCAPE '\\')" | |
| + | ); | |
| + | params.q = like; | |
| + | } | |
| + | if (filter.congress) { | |
| + | clauses.push("b.congress = :congress"); | |
| + | params.congress = filter.congress; | |
| + | } | |
| + | if (filter.policyArea) { | |
| + | clauses.push("b.policy_area = :policyArea"); | |
| + | params.policyArea = filter.policyArea; | |
| + | } | |
| + | if (filter.status) { | |
| + | clauses.push("b.status LIKE :status ESCAPE '\\'"); | |
| + | params.status = `%${filter.status.replace(/[%_\\]/g, (m) => "\\" + m)}%`; | |
| + | } | |
| + | if (filter.state) { | |
| + | // Join bills_ext to filter by sponsor state. | |
| + | params.sponsorState = filter.state.toUpperCase(); | |
| + | } | |
| + | ||
| + | const hasState = !!filter.state; | |
| + | const from = hasState | |
| + | ? "bills b JOIN bills_ext e ON e.bill_id = b.id" | |
| + | : "bills b"; | |
| + | if (hasState) { | |
| + | clauses.push("UPPER(e.sponsor_state) = :sponsorState"); | |
| + | } | |
| + | ||
| + | const where = clauses.length ? " WHERE " + clauses.join(" AND ") : ""; | |
| + | return { where: `FROM ${from}${where}`, params }; | |
| + | } | |
| + | ||
| + | /** Count bills matching the given filter without fetching rows. */ | |
| + | export function getBillsCount(filter: BillFilter = {}): number { | |
| + | const { where, params } = buildBillsWhere(filter); | |
| + | const row = safeGet<{ c: number }>( | |
| + | `SELECT COUNT(*) AS c ${where}`, | |
| + | params | |
| + | ); | |
| + | return row?.c ?? 0; | |
| + | } | |
| + | ||
| + | export function getBills(filter: BillFilter = {}, limit = 100, offset = 0): BillListItem[] { | |
| + | const { where, params } = buildBillsWhere(filter); | |
| + | const orderBy = filter.sort === "action" | |
| + | ? "b.updated_at DESC" | |
| + | : "b.updated_at DESC"; | |
| + | return safeAll<BillListItem>( | |
| `SELECT b.id, b.congress, b.number, b.title, b.status, b.policy_area, | ||
| b.updated_at, b.source_url | ||
| - | FROM bills b | |
| - | ORDER BY b.updated_at DESC`, | |
| - | {}, | |
| + | ${where} | |
| + | ORDER BY ${orderBy} | |
| + | LIMIT :limit OFFSET :offset`, | |
| + | { ...params, limit, offset }, | |
| (r) => ({ | ||
| id: `bill:${r.id}`, | ||
| number: str(r.number), | ||
| @@ -752,44 +888,6 @@ export function getBills(filter: BillFilter = {}): BillListItem[] { | ||
| href: `/bill/${r.id}`, | ||
| }) | ||
| ); | ||
| - | ||
| - | let out = items; | |
| - | const q = filter.q?.trim().toLowerCase(); | |
| - | if (q) { | |
| - | out = out.filter( | |
| - | (b) => | |
| - | b.number.toLowerCase().includes(q) || | |
| - | b.title.toLowerCase().includes(q) || | |
| - | b.policyArea.toLowerCase().includes(q) | |
| - | ); | |
| - | } | |
| - | if (filter.congress) { | |
| - | out = out.filter((b) => b.congress === filter.congress); | |
| - | } | |
| - | if (filter.policyArea) { | |
| - | const p = filter.policyArea.toLowerCase(); | |
| - | out = out.filter((b) => b.policyArea.toLowerCase() === p); | |
| - | } | |
| - | if (filter.status) { | |
| - | const s = filter.status.toLowerCase(); | |
| - | out = out.filter((b) => b.status.toLowerCase().includes(s)); | |
| - | } | |
| - | // state filter applies via sponsor state (bills_ext); optional. | |
| - | if (filter.state) { | |
| - | const st = filter.state.toUpperCase(); | |
| - | const bySponsorState = new Set( | |
| - | safeAll<{ billId: number }>( | |
| - | `SELECT bill_id FROM bills_ext WHERE UPPER(sponsor_state) = :st`, | |
| - | { st }, | |
| - | (r) => ({ billId: num(r.bill_id) }) | |
| - | ).map((x) => x.billId) | |
| - | ); | |
| - | out = out.filter((b) => bySponsorState.has(parseInt(b.id.replace(/^bill:/, ""), 10))); | |
| - | } | |
| - | if (filter.sort === "action") { | |
| - | out = [...out].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); | |
| - | } | |
| - | return out; | |
| } | ||
| /** Distinct policy areas present in the record (for browse filters). */ | ||
| @@ -804,13 +902,15 @@ export function getBillPolicyAreas(): string[] { | ||
| return rows.map((r) => r.policy_area); | ||
| } | ||
| - | /** Distinct congress numbers present, descending. */ | |
| + | /** Distinct congress numbers present, descending. Cached. */ | |
| export function getBillCongresses(): number[] { | ||
| - | return safeAll<{ congress: number }>( | |
| - | `SELECT DISTINCT congress FROM bills WHERE congress IS NOT NULL ORDER BY congress DESC`, | |
| - | {}, | |
| - | (r) => ({ congress: num(r.congress) }) | |
| - | ).map((r) => r.congress); | |
| + | return cached("bills:congresses", () => | |
| + | safeAll<{ congress: number }>( | |
| + | `SELECT DISTINCT congress FROM bills WHERE congress IS NOT NULL ORDER BY congress DESC`, | |
| + | {}, | |
| + | (r) => ({ congress: num(r.congress) }) | |
| + | ).map((r) => r.congress) | |
| + | ); | |
| } | ||
| export function getBillDetail(idNum: number): BillDetail | null { | ||
| @@ -967,14 +1067,60 @@ export type MemberFilter = { | ||
| party?: PartyCode; | ||
| }; | ||
| - | export function getMembers(filter: MemberFilter = {}): MemberSummary[] { | |
| - | const items = safeAll<MemberSummary>( | |
| - | `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url | |
| - | FROM members | |
| - | ORDER BY chamber, state, full_name`, | |
| - | {}, | |
| - | (r) => toMemberSummary(r) | |
| + | /** All members - cached so the members page and per-state filtering don't re-query. */ | |
| + | function getAllMembersRaw(): MemberSummary[] { | |
| + | return cached("members:all", () => | |
| + | safeAll<MemberSummary>( | |
| + | `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url | |
| + | FROM members | |
| + | ORDER BY chamber, state, full_name`, | |
| + | {}, | |
| + | (r) => toMemberSummary(r) | |
| + | ) | |
| ); | ||
| + | } | |
| + | ||
| + | /** | |
| + | * Current Congress members only (IDs 1-600, the 119th sitting House + Senate). | |
| + | * This is the default SSR seed for the members page - ~537 members instead of | |
| + | * 12.7k. Historical members are fetched lazily via /api/members when the user | |
| + | * opens the disclosure toggle. Keeps the SSR HTML payload under 1MB. | |
| + | */ | |
| + | export function getMembersDefault(): MemberSummary[] { | |
| + | return cached("members:current", () => { | |
| + | const all = getAllMembersRaw(); | |
| + | return all.filter((m) => m.isCurrent); | |
| + | }); | |
| + | } | |
| + | ||
| + | /** | |
| + | * Cheap aggregate counts for the members page summary cards. | |
| + | * Avoids serializing 12.7k members just to count them. | |
| + | */ | |
| + | export function getMemberCounts(): { total: number; senate: number; house: number } { | |
| + | return cached("members:counts", () => { | |
| + | const total = safeCount("members"); | |
| + | const senateRow = safeGet<{ c: number }>( | |
| + | `SELECT COUNT(*) AS c FROM members WHERE LOWER(chamber) = 'senate'` | |
| + | ); | |
| + | const houseRow = safeGet<{ c: number }>( | |
| + | `SELECT COUNT(*) AS c FROM members WHERE LOWER(chamber) = 'house'` | |
| + | ); | |
| + | return { | |
| + | total, | |
| + | senate: senateRow?.c ?? 0, | |
| + | house: houseRow?.c ?? 0, | |
| + | }; | |
| + | }); | |
| + | } | |
| + | ||
| + | export function getMembers(filter: MemberFilter = {}): MemberSummary[] { | |
| + | const items = getAllMembersRaw(); | |
| + | ||
| + | // When no filter is active, return the full cached list (fast path). | |
| + | if (!filter.q && !filter.state && !filter.chamber && !filter.party) { | |
| + | return items; | |
| + | } | |
| let out = items; | ||
| const q = filter.q?.trim().toLowerCase(); | ||
| @@ -1011,6 +1157,12 @@ export function getMemberStates(): string[] { | ||
| } | ||
| export function getMember(rawId: string): MemberDetail | null { | ||
| + | // Cache: generateMetadata + the page component both call getMember for the | |
| + | // same id in the same request cycle. The cache prevents duplicate DB work. | |
| + | return cached(`member:${rawId}`, () => _getMemberImpl(rawId)); | |
| + | } | |
| + | ||
| + | function _getMemberImpl(rawId: string): MemberDetail | null { | |
| // Accept "member:181", "181", or a bioguide id. | ||
| const cleaned = rawId.replace(/^member:/, ""); | ||
| const numeric = parseInt(cleaned, 10); | ||
| @@ -1095,32 +1247,18 @@ export function getMember(rawId: string): MemberDetail | null { | ||
| { mid: summary.numericId } | ||
| )?.c ?? votes.length; | ||
| - | // Year-by-year index of EVERY recorded position (the member-year headline | |
| - | // view). Uses the votes.year column; undated votes bucket to null → last. | |
| - | const allMemberVotes = safeAll<MemberVote & { year: number | null }>( | |
| - | `SELECT v.id, v.date, v.year, v.chamber, v.question, v.result, v.yea, v.nay, | |
| - | v.source_url, vp.position | |
| + | // Year-by-year summary - cheap GROUP BY, no vote rows serialised into SSR. | |
| + | // Votes for each year are fetched lazily by the MemberVoteYear client component. | |
| + | const votesByYear = safeAll<MemberYearGroup>( | |
| + | `SELECT v.year, COUNT(*) AS count | |
| FROM vote_positions vp | ||
| JOIN votes v ON v.id = vp.vote_id | ||
| WHERE vp.member_id = :mid | ||
| - | ORDER BY v.date DESC, v.id DESC | |
| - | LIMIT 1500`, | |
| + | GROUP BY v.year | |
| + | ORDER BY v.year IS NULL ASC, v.year DESC`, | |
| { mid: summary.numericId }, | ||
| - | (v) => ({ | |
| - | voteId: `vote:${v.id}`, | |
| - | voteHref: `/votes/${num(v.id)}`, | |
| - | date: str(v.date), | |
| - | chamber: str(v.chamber).toUpperCase() || "SENATE", | |
| - | question: str(v.question) || undefined, | |
| - | result: str(v.result), | |
| - | yea: num(v.yea), | |
| - | nay: num(v.nay), | |
| - | position: str(v.position) || "Not Voting", | |
| - | sourceUrl: ensureSource(v.source_url), | |
| - | year: nullableNum(v.year), | |
| - | }) | |
| + | (r) => ({ year: nullableNum(r.year), count: num(r.count) }) | |
| ); | ||
| - | const votesByYear: MemberYearGroup[] = groupMemberVotesByYear(allMemberVotes); | |
| return { | ||
| ...summary, | ||
| @@ -1132,23 +1270,50 @@ export function getMember(rawId: string): MemberDetail | null { | ||
| }; | ||
| } | ||
| - | /** Group a member's votes into year buckets, newest year first (undated last). */ | |
| - | function groupMemberVotesByYear( | |
| - | votes: Array<MemberVote & { year: number | null }> | |
| - | ): MemberYearGroup[] { | |
| - | const groups = new Map<number | null, MemberVote[]>(); | |
| - | for (const v of votes) { | |
| - | const arr = groups.get(v.year) ?? []; | |
| - | arr.push(v); | |
| - | groups.set(v.year, arr); | |
| - | } | |
| - | return Array.from(groups.entries()) | |
| - | .sort((a, b) => { | |
| - | if (a[0] == null) return 1; | |
| - | if (b[0] == null) return -1; | |
| - | return b[0] - a[0]; | |
| - | }) | |
| - | .map(([year, vs]) => ({ year, count: vs.length, votes: vs })); | |
| + | /** | |
| + | * Return all votes a member cast in a given year (or undated when year=null). | |
| + | * Called by GET /api/members/[id]/votes and cached per (bioguide, year). | |
| + | */ | |
| + | export function getMemberVotesForYear( | |
| + | bioguide: string, | |
| + | year: number | null, | |
| + | limit = 600 | |
| + | ): MemberVote[] { | |
| + | const cacheKey = `member-votes:${bioguide}:${year ?? "null"}`; | |
| + | return cached(cacheKey, () => { | |
| + | const memberRow = safeGet<{ id: number }>( | |
| + | `SELECT id FROM members WHERE bioguide_id = :bg`, | |
| + | { bg: bioguide } | |
| + | ); | |
| + | if (!memberRow) return []; | |
| + | const mid = memberRow.id; | |
| + | const yearClause = year == null ? `v.year IS NULL` : `v.year = :year`; | |
| + | const params: Record<string, unknown> = year == null | |
| + | ? { mid, lim: limit } | |
| + | : { mid, year, lim: limit }; | |
| + | return safeAll<MemberVote>( | |
| + | `SELECT v.id, v.date, v.chamber, v.question, v.result, v.yea, v.nay, | |
| + | v.source_url, vp.position | |
| + | FROM vote_positions vp | |
| + | JOIN votes v ON v.id = vp.vote_id | |
| + | WHERE vp.member_id = :mid AND ${yearClause} | |
| + | ORDER BY v.date DESC, v.id DESC | |
| + | LIMIT :lim`, | |
| + | params, | |
| + | (v) => ({ | |
| + | voteId: `vote:${num(v.id)}`, | |
| + | voteHref: `/votes/${num(v.id)}`, | |
| + | date: str(v.date), | |
| + | chamber: str(v.chamber).toUpperCase() || "SENATE", | |
| + | question: str(v.question) || undefined, | |
| + | result: str(v.result), | |
| + | yea: num(v.yea), | |
| + | nay: num(v.nay), | |
| + | position: str(v.position) || "Not Voting", | |
| + | sourceUrl: ensureSource(v.source_url), | |
| + | }) | |
| + | ); | |
| + | }); | |
| } | ||
| function toMemberSummary(r: Record<string, unknown>): MemberSummary { | ||
| @@ -1450,6 +1615,30 @@ export type CalendarEvent = { | ||
| confidence: number; | ||
| }; | ||
| + | /** | |
| + | * Per-day kind counts for the calendar grid - a lightweight alternative to | |
| + | * fetching the full CalendarEvent list. Returns one row per (date, kind) | |
| + | * combination so the grid can render event-type dots without serializing | |
| + | * 2,700+ full event objects into the page's RSC props. | |
| + | */ | |
| + | export type CalendarDayKind = { date: string; kind: CalendarKind; count: number }; | |
| + | ||
| + | export function getCalendarDaySummary(from: string, to: string): CalendarDayKind[] { | |
| + | return safeAll<CalendarDayKind>( | |
| + | `SELECT date, kind, COUNT(*) AS count | |
| + | FROM calendar_events | |
| + | WHERE date >= :from AND date <= :to | |
| + | GROUP BY date, kind | |
| + | ORDER BY date ASC, kind ASC`, | |
| + | { from, to }, | |
| + | (r) => ({ | |
| + | date: str(r.date), | |
| + | kind: str(r.kind) as CalendarKind, | |
| + | count: num(r.count), | |
| + | }) | |
| + | ); | |
| + | } | |
| + | ||
| /** | ||
| * Events in an inclusive date range (YYYY-MM-DD), optionally filtered by | ||
| * kind. Pulls from calendar_events - the parsed "takes effect / enacted / | ||
| @@ -1625,37 +1814,42 @@ export type CategorySummary = { | ||
| * so the browse stays complete and honest about topics with no record yet. | ||
| */ | ||
| export function getCategories(): CategorySummary[] { | ||
| - | return safeAll<CategorySummary>( | |
| - | `SELECT c.slug, c.label, c.kind, | |
| - | (SELECT COUNT(*) FROM bills b WHERE b.policy_area = c.label) AS bill_count, | |
| - | (SELECT COUNT(*) FROM awards a WHERE a.category = c.label) AS award_count | |
| - | FROM categories c | |
| - | ORDER BY c.kind, c.label`, | |
| - | {}, | |
| - | (r) => { | |
| - | const billCount = num(r.bill_count); | |
| - | const awardCount = num(r.award_count); | |
| - | return { | |
| - | slug: str(r.slug), | |
| - | label: str(r.label), | |
| - | kind: str(r.kind), | |
| - | billCount, | |
| - | awardCount, | |
| - | total: billCount + awardCount, | |
| - | }; | |
| - | } | |
| + | // Cache: correlated subqueries over 208k bills are expensive; 5-min TTL is fine. | |
| + | return cached("categories:all", () => | |
| + | safeAll<CategorySummary>( | |
| + | `SELECT c.slug, c.label, c.kind, | |
| + | (SELECT COUNT(*) FROM bills b WHERE b.policy_area = c.label) AS bill_count, | |
| + | (SELECT COUNT(*) FROM awards a WHERE a.category = c.label) AS award_count | |
| + | FROM categories c | |
| + | ORDER BY c.kind, c.label`, | |
| + | {}, | |
| + | (r) => { | |
| + | const billCount = num(r.bill_count); | |
| + | const awardCount = num(r.award_count); | |
| + | return { | |
| + | slug: str(r.slug), | |
| + | label: str(r.label), | |
| + | kind: str(r.kind), | |
| + | billCount, | |
| + | awardCount, | |
| + | total: billCount + awardCount, | |
| + | }; | |
| + | } | |
| + | ) | |
| ); | ||
| } | ||
| - | /** Distinct policy-area labels in use (for browse filters / fallback). */ | |
| + | /** Distinct policy-area labels in use (for browse filters / fallback). Cached. */ | |
| export function getPolicyAreaLabels(): string[] { | ||
| - | return safeAll<{ label: string }>( | |
| - | `SELECT DISTINCT policy_area AS label FROM bills | |
| - | WHERE policy_area IS NOT NULL AND policy_area != '' | |
| - | ORDER BY policy_area`, | |
| - | {}, | |
| - | (r) => ({ label: str(r.label) }) | |
| - | ).map((r) => r.label); | |
| + | return cached("bills:policyareas", () => | |
| + | safeAll<{ label: string }>( | |
| + | `SELECT DISTINCT policy_area AS label FROM bills | |
| + | WHERE policy_area IS NOT NULL AND policy_area != '' | |
| + | ORDER BY policy_area`, | |
| + | {}, | |
| + | (r) => ({ label: str(r.label) }) | |
| + | ).map((r) => r.label) | |
| + | ); | |
| } | ||
| /** Top legislative subjects tagged on a bill (bill_subjects). */ |