● Zion Boggan zionboggan.com ↗

Perf: SQL-level pagination + lazy member-year votes + indexes + TTL cache (register 16x, member page 294x); REDESIGN.md spec (light default, cite-this, declutter)

7afe066   Zion Boggan committed on Jun 27, 2026 (1 month ago)
PERF_NOTES.md +134 -0
@@ -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
app/api/bills/route.ts +8 -7
@@ -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);
}
app/api/members/[id]/votes/route.ts +27 -0
@@ -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 });
+}
app/api/members/route.ts +8 -4
@@ -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);
app/api/votes/route.ts +6 -9
@@ -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);
}
app/bill/[id]/page.tsx +1 -1
@@ -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,
app/bills/page.tsx +9 -7
@@ -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 ? (
app/calendar/page.tsx +160 -88
@@ -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>
);
})}
app/categories/page.tsx +1 -1
@@ -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",
app/executive/page.tsx +1 -1
@@ -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",
app/members/[id]/page.tsx +12 -22
@@ -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>
</>
app/members/page.tsx +20 -18
@@ -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>
app/register/page.tsx +13 -9
@@ -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
app/schedule/page.tsx +1 -1
@@ -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",
app/search/page.tsx +1 -1
@@ -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,
app/spending/page.tsx +1 -1
@@ -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. */
app/states/[code]/page.tsx +1 -1
@@ -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,
app/states/page.tsx +1 -1
@@ -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)
app/votes/[id]/page.tsx +1 -1
@@ -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,
app/votes/page.tsx +7 -5
@@ -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."
/>
components/member-vote-year.tsx +143 -0
@@ -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}
+ />
+ );
+}
components/members-browser.tsx +52 -11
@@ -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>
docs/REDESIGN.md +43 -0
@@ -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.
lib/feed.ts +353 -159
@@ -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). */