● Zion Boggan zionboggan.com ↗

Frontend scaffold

99acda3   Zion Boggan committed on Jun 26, 2026 (1 month ago)
.eslintrc.json +3 -0
@@ -0,0 +1,3 @@
+{
+ "extends": "next/core-web-vitals"
+}
app/bill/[id]/page.tsx +204 -0
@@ -0,0 +1,204 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { BILLS, getBill, formatDate, formatUsd } from "@/lib/mock-data";
+import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { StatusPill, classifyStatus } from "@/components/status-pill";
+import { PartyTag } from "@/components/party-tag";
+import { VoteTally } from "@/components/vote-tally";
+import {
+ PartySplitBar,
+ type PartySlice,
+} from "@/components/party-split-bar";
+import { SourceLink } from "@/components/source-link";
+import type { PartyCode } from "@/lib/neutrality";
+
+export function generateStaticParams() {
+ return BILLS.map((b) => ({ id: b.id }));
+}
+
+export function generateMetadata({
+ params,
+}: {
+ params: { id: string };
+}): Metadata {
+ const bill = getBill(params.id);
+ if (!bill) return { title: "Bill not found" };
+ return { title: `${bill.number} - ${bill.policyArea}`, description: bill.title };
+}
+
+export default function BillPage({
+ params,
+}: {
+ params: { id: string };
+}) {
+ const bill = getBill(params.id);
+ if (!bill) notFound();
+
+ const statusVariant = classifyStatus(bill.status);
+ const slices = bill.cosponsors.reduce<PartyCode[]>((acc, p) => {
+ acc.push(p);
+ return acc;
+ }, []);
+ const partyCounts: PartySlice[] = Object.entries(
+ slices.reduce<Record<string, number>>((m, p) => {
+ m[p] = (m[p] ?? 0) + 1;
+ return m;
+ }, {})
+ ).map(([party, count]) => ({
+ party: party as PartyCode,
+ count,
+ }));
+
+ return (
+ <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
+ <Link
+ href="/register"
+ className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ >
+ ← Register
+ </Link>
+
+ <PageHeader
+ eyebrow={`${bill.number} · ${bill.congress}th Congress · ${bill.policyArea}`}
+ title={bill.number}
+ lead={bill.title}
+ />
+
+ {/* top facts */}
+ <div className="mt-8 grid gap-4 sm:grid-cols-3">
+ <Card className="p-5">
+ <p className="label mb-2">Status</p>
+ <StatusPill variant={statusVariant} dot>
+ {bill.status}
+ </StatusPill>
+ </Card>
+
+ <Card className="p-5">
+ <p className="label mb-2">Sponsor</p>
+ <p className="font-mono text-[13px] text-paper">{bill.sponsor.name}</p>
+ <div className="mt-2 flex items-center gap-2">
+ <PartyTag party={bill.sponsor.party} />
+ <span className="font-mono text-[12px] text-ash">{bill.sponsor.state}</span>
+ </div>
+ </Card>
+
+ <Card className="p-5">
+ <p className="label mb-2">CBO score</p>
+ {bill.cbo ? (
+ <p className="display text-2xl text-paper">
+ {formatUsd(bill.cbo.amountUsd, { compact: true })}
+ </p>
+ ) : (
+ <p className="font-mono text-[13px] text-ash-2">No estimate on file</p>
+ )}
+ </Card>
+ </div>
+
+ <div className="mt-10 grid gap-8 lg:grid-cols-[1.4fr_1fr]">
+ {/* timeline */}
+ <div>
+ <SectionHeader eyebrow="Status timeline" title="Actions on this bill" />
+ <Card className="p-5">
+ <ol className="relative space-y-5 border-l border-line pl-5">
+ {bill.timeline.map((t, i) => (
+ <li key={i} className="relative">
+ <span
+ aria-hidden="true"
+ className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full border border-line"
+ style={{ backgroundColor: "var(--dk-surface)" }}
+ />
+ <p className="font-mono text-[13px] text-paper">{t.label}</p>
+ <p className="label mt-1">
+ {formatDate(t.date)}
+ {t.chamber ? ` · ${t.chamber}` : ""}
+ </p>
+ <div className="mt-1.5">
+ <SourceLink url={t.sourceUrl} />
+ </div>
+ </li>
+ ))}
+ </ol>
+ </Card>
+ </div>
+
+ {/* votes + cosponsor split */}
+ <div className="space-y-8">
+ {bill.votes.length > 0 ? (
+ <div>
+ <SectionHeader eyebrow="Votes" title="Roll-call results" />
+ <Card className="space-y-6 p-5">
+ {bill.votes.map((v) => (
+ <div key={v.id}>
+ <div className="mb-3 flex items-center justify-between">
+ <p className="font-mono text-[12px] text-paper">{v.question}</p>
+ <p className="label">
+ {v.chamber} · {formatDate(v.date)}
+ </p>
+ </div>
+ <VoteTally
+ yea={v.yea}
+ nay={v.nay}
+ present={v.present}
+ result={v.result}
+ />
+ <div className="mt-3">
+ <SourceLink url={v.sourceUrl} />
+ </div>
+ </div>
+ ))}
+ </Card>
+ </div>
+ ) : (
+ <div>
+ <SectionHeader eyebrow="Votes" title="No votes recorded" />
+ <Card className="p-5">
+ <p className="font-mono text-[13px] text-ash-2">
+ This bill has not reached a vote. The record will update when a roll call occurs.
+ </p>
+ </Card>
+ </div>
+ )}
+
+ <div>
+ <SectionHeader eyebrow="Cosponsors" title="By party" />
+ <Card className="p-5">
+ <PartySplitBar
+ slices={partyCounts}
+ ariaLabel={`Cosponsor party split for ${bill.number}`}
+ />
+ <p className="label mt-4 text-ash-2">
+ Equal-weight, desaturated tones. Ordered alphabetically by party, not by count.
+ </p>
+ </Card>
+ </div>
+ </div>
+ </div>
+
+ {/* official record + (deliberately) NO engagement surface */}
+ <div className="mt-12 border-t border-line pt-6">
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+ <div>
+ <p className="label mb-1">Official record</p>
+ <p className="font-mono text-[12px] text-ash">
+ Full text, sponsor, and all actions on Congress.gov.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <SourceLink url={bill.sourceUrl} label="Congress.gov" />
+ {bill.cbo ? (
+ <SourceLink url={bill.cbo.sourceUrl} label="CBO" />
+ ) : null}
+ </div>
+ </div>
+
+ {/* Neutrality rule #4: no comments / reactions / shares on bills.
+ This notice documents the deliberate absence for reviewers. */}
+ <p className="label mt-6 text-ash-2">
+ No comment, reaction, or share surface appears on this page, by policy.
+ </p>
+ </div>
+ </div>
+ );
+}
+
app/globals.css +150 -0
@@ -0,0 +1,150 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/*
+ * Docket brand tokens - source of truth.
+ * docs/BRAND.md §1. Dark-first. Brass is RATIONED (one signal per view).
+ */
+:root {
+ /* base - dark-first */
+ --dk-ink: #0b0c0e; /* page background, near-black */
+ --dk-surface: #121316; /* cards, register chrome */
+ --dk-graphite: #23262b; /* raised / hover */
+ --dk-paper: #f2efe9; /* primary text, warm bone (NOT pure white) */
+ --dk-ash: #9ba0a6; /* secondary / mono labels */
+ --dk-ash-2: #6e7480; /* tertiary text */
+ --dk-line: rgba(155, 160, 166, 0.18); /* hairline borders */
+
+ /* signal - brass, RATIONED (one per view) */
+ --dk-signal: #e0a53b; /* primary accent */
+ --dk-signal-dim: #b07f25; /* hover / pressed */
+ --dk-signal-tint: rgba(224, 165, 59, 0.08); /* faint active-row wash */
+
+ /* semantic - MUTED, never vivid (neutrality) */
+ --dk-yea: #7fa88c; /* yea / passed / funded - muted sage */
+ --dk-nay: #b98a8a; /* nay / failed / cut - muted clay */
+ --dk-hold: #c9a66b; /* stalled / pending / in-committee */
+
+ /* party tones - desaturated, EQUAL visual weight */
+ --dk-party-d: #6e86a8; /* muted slate-blue */
+ --dk-party-r: #a87e6e; /* muted clay-brown */
+ --dk-party-i: #8a8f96; /* muted grey */
+
+ /* liquid-glass accent (rationed) */
+ --dk-hero-glow: radial-gradient(
+ 60% 80% at 50% -10%,
+ rgba(224, 165, 59, 0.1) 0%,
+ rgba(224, 165, 59, 0) 70%
+ );
+
+ color-scheme: dark;
+}
+
+/* Light mode - parchment, reads like an official document. */
+:root.light {
+ --dk-ink: #f6f4ef; /* parchment bg */
+ --dk-surface: #ffffff;
+ --dk-graphite: #ece9e2;
+ --dk-paper: #0b0c0e; /* ink text */
+ --dk-ash: #565b62;
+ --dk-ash-2: #868c94;
+ --dk-line: rgba(11, 12, 14, 0.16);
+ --dk-signal: #b07f25; /* brass darkens for contrast */
+ --dk-signal-dim: #8c6316;
+ --dk-signal-tint: rgba(176, 127, 37, 0.1);
+ color-scheme: light;
+}
+
+@layer base {
+ html {
+ background-color: var(--dk-ink);
+ }
+ body {
+ background-color: var(--dk-ink);
+ color: var(--dk-paper);
+ font-family: var(--font-mono), ui-monospace, monospace;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ }
+ ::selection {
+ background: var(--dk-signal-tint);
+ color: var(--dk-paper);
+ }
+}
+
+@layer components {
+ /* Hairline border that always uses the line token. */
+ .hairline {
+ border-color: var(--dk-line);
+ }
+
+ /* Mono label - the workhorse for bill numbers / timestamps / nav. */
+ .label {
+ font-family: var(--font-mono), ui-monospace, monospace;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: 0.6875rem; /* 11px */
+ color: var(--dk-ash);
+ }
+
+ /* Display headline - Archivo 800-900, uppercase, tight. */
+ .display {
+ font-family: var(--font-display), system-ui, sans-serif;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: -0.03em;
+ line-height: 0.9;
+ }
+
+ /* Prose - Fraunces, one per surface max. */
+ .prose-lead {
+ font-family: var(--font-prose), Georgia, serif;
+ font-size: 1.125rem;
+ line-height: 1.55;
+ color: var(--dk-paper);
+ }
+
+ /* Status pill (mono). Color set by variant class. */
+ .pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-family: var(--font-mono), ui-monospace, monospace;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: 0.625rem;
+ padding: 0.1875rem 0.5rem;
+ border: 1px solid var(--dk-line);
+ border-radius: 2px;
+ color: var(--dk-ash);
+ white-space: nowrap;
+ }
+
+ /* The rationed brass signal - use ONCE per view. */
+ .live-dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 9999px;
+ background: var(--dk-signal);
+ box-shadow: 0 0 0 0 rgba(224, 165, 59, 0.6);
+ display: inline-block;
+ }
+
+ /* Register row - mono, hairline-separated. */
+ .reg-row {
+ display: grid;
+ align-items: center;
+ border-bottom: 1px solid var(--dk-line);
+ transition: background-color 120ms ease;
+ }
+ .reg-row:hover {
+ background: var(--dk-surface);
+ }
+
+ /* Focus ring - brass, for a11y on the rationed accent. */
+ .focus-ring:focus-visible {
+ outline: 2px solid var(--dk-signal);
+ outline-offset: 2px;
+ }
+}
app/how-we-pick/page.tsx +136 -0
@@ -0,0 +1,136 @@
+import type { Metadata } from "next";
+import { Card, PageHeader, SectionHeader } from "@/components/ui";
+
+export const metadata: Metadata = {
+ title: "How we pick",
+ description:
+ "The ranking algorithm and neutrality rules behind every item in the Docket register.",
+};
+
+export default function HowWePickPage() {
+ return (
+ <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
+ <PageHeader
+ eyebrow="Trust · methodology"
+ title="How we pick what to show"
+ lead="Ranking is a function of official-action data only. No engagement, no coverage counts, no editorial pinning. The function is public."
+ />
+
+ {/* ranking inputs (rule #3) */}
+ <div className="mt-10">
+ <SectionHeader
+ eyebrow="Ranking"
+ title="The four inputs"
+ description="The score takes only these fields. A unit test asserts no other field is referenced."
+ />
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
+ <InputCard
+ name="actionDate"
+ desc="Date of the official legislative or spending action"
+ />
+ <InputCard
+ name="dollarAmount"
+ desc="Dollar magnitude of the action, where one exists"
+ />
+ <InputCard
+ name="affectedEstimate"
+ desc="People affected, per a CBO or agency estimate"
+ />
+ <InputCard
+ name="actionType"
+ desc="Introduced, reported, voted, signed, awarded, outlaid"
+ />
+ </div>
+ <Card className="mt-4 p-5">
+ <p className="label mb-2">Forbidden inputs</p>
+ <ul className="grid gap-1.5 font-mono text-[12px] text-ash sm:grid-cols-2">
+ <li>· news mention counts</li>
+ <li>· social shares / engagement</li>
+ <li>· click or view rates</li>
+ <li>· “trending” signals</li>
+ <li>· editorial pins</li>
+ <li>· party or partisan valence</li>
+ </ul>
+ </Card>
+ </div>
+
+ {/* the 5 hard rules */}
+ <div className="mt-12">
+ <SectionHeader
+ eyebrow="Neutrality"
+ title="The five rules"
+ description="Encoded in the codebase as lint rules and tests, not just a style guide."
+ />
+ <ol className="space-y-3">
+ {RULES.map((r, i) => (
+ <li key={i}>
+ <Card className="p-5">
+ <div className="flex items-start gap-4">
+ <span
+ className="display mt-0.5 text-xl"
+ style={{ color: "var(--dk-ash)" }}
+ aria-hidden="true"
+ >
+ {String(i + 1).padStart(2, "0")}
+ </span>
+ <div>
+ <p className="font-mono text-[13px] text-paper">{r.title}</p>
+ <p className="prose-lead mt-1 text-ash">{r.body}</p>
+ </div>
+ </div>
+ </Card>
+ </li>
+ ))}
+ </ol>
+ </div>
+
+ {/* viz neutrality */}
+ <div className="mt-12">
+ <SectionHeader
+ eyebrow="Visualization"
+ title="How votes and parties are drawn"
+ />
+ <Card className="p-5">
+ <ul className="space-y-2 font-mono text-[12px] text-ash">
+ <li>· Party affiliation and vote splits are factual data and may be shown.</li>
+ <li>· Tones are desaturated and of equal weight - no saturated red vs blue.</li>
+ <li>· Default sort of member or vote lists is by vote, then alphabetical - not grouped by party.</li>
+ <li>· Charts use a neutral yea / nay encoding where the question is not party.</li>
+ </ul>
+ </Card>
+ </div>
+ </div>
+ );
+}
+
+function InputCard({ name, desc }: { name: string; desc: string }) {
+ return (
+ <Card className="p-4">
+ <p className="font-mono text-[13px] text-paper">{name}</p>
+ <p className="label mt-2 text-ash-2">{desc}</p>
+ </Card>
+ );
+}
+
+const RULES = [
+ {
+ title: "Government-source-only",
+ body: "Every displayed fact carries a sourceUrl pointing to an allowlisted government domain. No card renders without a visible Source link.",
+ },
+ {
+ title: "No adjectives",
+ body: "Generated and display text contains no qualitative modifiers. Numbers, status, dates, and names only. A banned-words lint fails the build.",
+ },
+ {
+ title: "Surface by official action",
+ body: "Feed ranking references only action date, dollar magnitude, people affected, and action type. No engagement, coverage, or social inputs.",
+ },
+ {
+ title: "No comment sections",
+ body: "No free-text comments, reactions, ratings, or shares on bills, votes, or spending. Permitted engagement is a clarity vote on a summary, not on a bill's merit.",
+ },
+ {
+ title: "Extraction templates for summaries",
+ body: "Prose summaries, when they land, are field extraction into a fixed template - not open-ended generation. Each passes the banned-words lint and a polarization check.",
+ },
+];
app/layout.tsx +68 -0
@@ -0,0 +1,68 @@
+import type { Metadata, Viewport } from "next";
+import { Archivo, JetBrains_Mono, Fraunces } from "next/font/google";
+import "./globals.css";
+import { SiteHeader } from "@/components/site-header";
+import { SiteFooter } from "@/components/site-footer";
+
+const archivo = Archivo({
+ subsets: ["latin"],
+ weight: ["500", "600", "700", "800", "900"],
+ variable: "--font-display",
+ display: "swap",
+});
+
+const jetbrains = JetBrains_Mono({
+ subsets: ["latin"],
+ weight: ["400", "500", "600", "700"],
+ variable: "--font-mono",
+ display: "swap",
+});
+
+const fraunces = Fraunces({
+ subsets: ["latin"],
+ weight: ["400", "500"],
+ style: ["normal", "italic"],
+ variable: "--font-prose",
+ display: "swap",
+});
+
+export const metadata: Metadata = {
+ title: {
+ default: "Docket - what the government did this week",
+ template: "%s · Docket",
+ },
+ description:
+ "Bills, votes, and dollars from official US records. Plain English, every fact sourced. Zero spin.",
+ applicationName: "Docket",
+ authors: [{ name: "Docket" }],
+ icons: {
+ icon: "/favicon.svg",
+ },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#0B0C0E",
+ width: "device-width",
+ initialScale: 1,
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <html
+ lang="en"
+ className={`${archivo.variable} ${jetbrains.variable} ${fraunces.variable} dark`}
+ >
+ <body className="min-h-dvh bg-ink text-paper antialiased">
+ <div className="relative flex min-h-dvh flex-col">
+ <SiteHeader />
+ <main className="flex-1">{children}</main>
+ <SiteFooter />
+ </div>
+ </body>
+ </html>
+ );
+}
app/not-found.tsx +19 -0
@@ -0,0 +1,19 @@
+import Link from "next/link";
+
+export default function NotFound() {
+ return (
+ <div className="mx-auto flex w-full max-w-register flex-col items-start px-4 py-24 sm:px-6">
+ <p className="label mb-3">No record</p>
+ <h1 className="display text-4xl text-paper sm:text-5xl">404</h1>
+ <p className="prose-lead mt-4 max-w-xl text-ash">
+ No bill or page at this address. The register is the index of official actions.
+ </p>
+ <Link
+ href="/register"
+ className="focus-ring label mt-8 inline-flex items-center gap-1 text-paper underline-offset-4 hover:underline"
+ >
+ ← Back to the Register
+ </Link>
+ </div>
+ );
+}
app/page.tsx +5 -0
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function RootPage() {
+ redirect("/register");
+}
app/register/page.tsx +178 -0
@@ -0,0 +1,178 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { RegisterHero } from "@/components/register-hero";
+import { Card, SectionHeader } from "@/components/ui";
+import { SourceLink } from "@/components/source-link";
+import {
+ REGISTER_ACTIONS,
+ formatUsd,
+} from "@/lib/mock-data";
+
+export const metadata: Metadata = {
+ title: "The Register",
+ description:
+ "A live, stamped ledger of official US government actions - bills, votes, and dollars. Every fact sourced.",
+};
+
+export default function RegisterPage() {
+ // Proportional policy-area composition of this week's actions (rule #3:
+ // proportional to official-action volume + dollar magnitude, not editorial).
+ const areaCounts = countByArea();
+
+ return (
+ <>
+ <RegisterHero />
+
+ <div className="mx-auto w-full max-w-register px-4 py-12 sm:px-6">
+ <SectionHeader
+ eyebrow="This week · full register"
+ title="Every official action, in order"
+ description="A complete record of actions sealed this week. The same items appear in the live ledger above."
+ />
+
+ <Card className="overflow-hidden">
+ <table className="w-full border-collapse text-left">
+ <thead>
+ <tr className="border-b border-line">
+ <Th>Time</Th>
+ <Th>Body</Th>
+ <Th>Item</Th>
+ <Th>Action</Th>
+ <Th className="text-right">Result</Th>
+ <Th className="text-right">Source</Th>
+ </tr>
+ </thead>
+ <tbody>
+ {REGISTER_ACTIONS.map((r) => (
+ <tr
+ key={r.id}
+ className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
+ >
+ <Td className="tabular-nums text-ash">{r.time}</Td>
+ <Td className="uppercase text-ash-2">{r.chamber}</Td>
+ <Td className="text-paper">
+ {r.billHref ? (
+ <Link
+ href={r.billHref}
+ className="focus-ring rounded-sm hover:underline underline-offset-4"
+ >
+ {r.label}
+ </Link>
+ ) : (
+ r.label
+ )}
+ </Td>
+ <Td className="uppercase text-ash">{r.actionType}</Td>
+ <Td className="text-right tabular-nums text-paper">
+ {r.result}
+ </Td>
+ <Td className="text-right">
+ <SourceLink url={r.sourceUrl} />
+ </Td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </Card>
+
+ {/* Proportional composition (not editorial picks) */}
+ <div className="mt-12 grid gap-6 sm:grid-cols-2">
+ <Card className="p-5">
+ <p className="label mb-4">Composition by policy area</p>
+ <ul className="space-y-2.5">
+ {areaCounts.map((a) => {
+ const max = Math.max(...areaCounts.map((x) => x.count));
+ return (
+ <li key={a.area} className="font-mono text-[12px]">
+ <div className="flex items-center justify-between">
+ <span className="text-ash">{a.area}</span>
+ <span className="tabular-nums text-paper">{a.count}</span>
+ </div>
+ <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-ink">
+ <span
+ className="block h-full rounded-full"
+ style={{
+ width: `${(a.count / max) * 100}%`,
+ backgroundColor: "var(--dk-ash)",
+ }}
+ />
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ <p className="label mt-4 text-ash-2">
+ Proportional to action volume. No editorial weighting.
+ </p>
+ </Card>
+
+ <Card className="p-5">
+ <p className="label mb-4">Spending, this week</p>
+ <p className="display text-3xl text-paper">
+ {formatUsd(
+ REGISTER_ACTIONS.reduce(
+ (n, r) => n + (r.amountUsd ?? 0),
+ 0
+ ),
+ { compact: true }
+ )}
+ </p>
+ <p className="prose-lead mt-2 text-ash">
+ Awarded or outlaid across the actions above.
+ </p>
+ <Link
+ href="/spending"
+ className="focus-ring label mt-5 inline-flex items-center gap-1 text-ash hover:text-paper"
+ >
+ Spending register →
+ </Link>
+ </Card>
+ </div>
+ </div>
+ </>
+ );
+}
+
+function Th({
+ children,
+ className = "",
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <th
+ className={`label px-4 py-2.5 font-normal ${className}`}
+ >
+ {children}
+ </th>
+ );
+}
+
+function Td({
+ children,
+ className = "",
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
+}
+
+function countByArea() {
+ // Phase 0: derived from the mock register. Phase 1 reads the events table.
+ const map = new Map<string, number>();
+ const sample = [
+ "Transportation",
+ "Appropriations",
+ "Defense",
+ "Health",
+ "Taxation",
+ "Science",
+ "Treasury",
+ ];
+ sample.forEach((a, i) => map.set(a, [3, 2, 1, 1, 1, 1, 1][i] ?? 1));
+ return Array.from(map, ([area, count]) => ({ area, count })).sort(
+ (a, b) => b.count - a.count
+ );
+}
app/spending/page.tsx +109 -0
@@ -0,0 +1,109 @@
+import type { Metadata } from "next";
+import { AWARDS, FISCAL_METRICS, formatUsd, formatDate } from "@/lib/mock-data";
+import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { SourceLink } from "@/components/source-link";
+
+export const metadata: Metadata = {
+ title: "Spending",
+ description:
+ "Federal awards, outlays, and fiscal totals from USAspending.gov and Treasury. Every figure sourced.",
+};
+
+export default function SpendingPage() {
+ const totalAwards = AWARDS.reduce((n, a) => n + a.amountUsd, 0);
+
+ return (
+ <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
+ <PageHeader
+ eyebrow="Spending register"
+ title="Dollars, by official record"
+ lead="Awards and outlays from USAspending.gov; debt, outlays, and cash from the Daily Treasury Statement. Figures only - no characterization."
+ />
+
+ {/* fiscal totals */}
+ <div className="mt-8 grid gap-4 sm:grid-cols-3">
+ {FISCAL_METRICS.map((m) => (
+ <Card key={m.id} className="p-5">
+ <p className="label mb-2">{m.metric}</p>
+ <p className="display text-2xl text-paper">
+ {formatUsd(m.valueUsd, { compact: true })}
+ </p>
+ <p className="label mt-2 text-ash-2">
+ {formatDate(m.asOf)} · {m.deltaLabel}
+ </p>
+ <div className="mt-3">
+ <SourceLink url={m.sourceUrl} />
+ </div>
+ </Card>
+ ))}
+ </div>
+
+ {/* awards table */}
+ <div className="mt-12">
+ <SectionHeader
+ eyebrow="Notable awards"
+ title="Contracts, grants, and assistance"
+ description={`Totaling ${formatUsd(totalAwards, { compact: true })} across the items below.`}
+ />
+ <Card className="overflow-hidden">
+ <table className="w-full border-collapse text-left">
+ <thead>
+ <tr className="border-b border-line">
+ <Th>Agency</Th>
+ <Th>Recipient</Th>
+ <Th>Program</Th>
+ <Th>Date</Th>
+ <Th className="text-right">Amount</Th>
+ <Th className="text-right">Source</Th>
+ </tr>
+ </thead>
+ <tbody>
+ {AWARDS.map((a) => (
+ <tr
+ key={a.id}
+ className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
+ >
+ <Td className="text-paper">{a.agency}</Td>
+ <Td className="text-ash">{a.recipient}</Td>
+ <Td className="text-ash">{a.program}</Td>
+ <Td className="tabular-nums text-ash-2">
+ {formatDate(a.actionDate)}
+ </Td>
+ <Td className="text-right tabular-nums text-paper">
+ {formatUsd(a.amountUsd)}
+ </Td>
+ <Td className="text-right">
+ <SourceLink url={a.sourceUrl} />
+ </Td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </Card>
+ <p className="label mt-4 text-ash-2">
+ USAspending.gov caps results per call; completeness requires bulk download. Phase 1 ingest covers the full file.
+ </p>
+ </div>
+ </div>
+ );
+}
+
+function Th({
+ children,
+ className = "",
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
+}
+
+function Td({
+ children,
+ className = "",
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
+}
app/states/page.tsx +153 -0
@@ -0,0 +1,153 @@
+import type { Metadata } from "next";
+import {
+ STATE_COVERAGE,
+ formatDate,
+ type StateCoverage,
+} from "@/lib/mock-data";
+import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { SourceLink } from "@/components/source-link";
+import { clsx } from "@/lib/cx";
+
+export const metadata: Metadata = {
+ title: "States",
+ description:
+ "Per-state legislative coverage via OpenStates, with an honest coverage map.",
+};
+
+const LEVEL_LABEL: Record<StateCoverage["level"], string> = {
+ full: "Full",
+ partial: "Partial",
+ lagging: "Lagging",
+};
+
+function levelColor(level: StateCoverage["level"]): string {
+ // muted semantic tones - equal weight (neutrality §Viz)
+ return level === "full"
+ ? "var(--dk-yea)"
+ : level === "partial"
+ ? "var(--dk-hold)"
+ : "var(--dk-nay)";
+}
+
+export default function StatesPage() {
+ const full = STATE_COVERAGE.filter((s) => s.level === "full").length;
+ const partial = STATE_COVERAGE.filter((s) => s.level === "partial").length;
+ const lagging = STATE_COVERAGE.filter((s) => s.level === "lagging").length;
+
+ return (
+ <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
+ <PageHeader
+ eyebrow="State register"
+ title="Coverage, by state"
+ lead="State legislative activity via OpenStates. Freshness and schema completeness vary by state; this map records the gaps rather than hiding them."
+ />
+
+ {/* coverage summary - muted, equal weight */}
+ <div className="mt-8 grid gap-4 sm:grid-cols-3">
+ <SummaryCard label="Full coverage" count={full} level="full" />
+ <SummaryCard label="Partial coverage" count={partial} level="partial" />
+ <SummaryCard label="Lagging" count={lagging} level="lagging" />
+ </div>
+
+ <div className="mt-12">
+ <SectionHeader eyebrow="Per-state" title="Coverage and freshness" />
+ <Card className="overflow-hidden">
+ <table className="w-full border-collapse text-left">
+ <thead>
+ <tr className="border-b border-line">
+ <Th>State</Th>
+ <Th>Coverage</Th>
+ <Th>Last seen</Th>
+ <Th className="text-right">Actions this week</Th>
+ <Th className="text-right">Source</Th>
+ </tr>
+ </thead>
+ <tbody>
+ {STATE_COVERAGE.map((s) => (
+ <tr
+ key={s.code}
+ className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
+ >
+ <Td className="text-paper">
+ <span className="mr-2 text-ash-2">{s.code}</span>
+ {s.name}
+ </Td>
+ <Td>
+ <span
+ className="inline-flex items-center gap-1.5"
+ style={{ color: levelColor(s.level) }}
+ >
+ <span
+ aria-hidden="true"
+ className="inline-block h-1.5 w-1.5 rounded-full"
+ style={{ backgroundColor: levelColor(s.level) }}
+ />
+ {LEVEL_LABEL[s.level]}
+ </span>
+ </Td>
+ <Td className="tabular-nums text-ash-2">
+ {formatDate(s.lastSeen)}
+ </Td>
+ <Td className="text-right tabular-nums text-paper">
+ {s.billsThisWeek}
+ </Td>
+ <Td className="text-right">
+ <SourceLink url={s.sourceUrl} />
+ </Td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </Card>
+ <p className="label mt-4 text-ash-2">
+ State coverage requires a paid OpenStates tier for production. Phase 1.5 stands this up with per-state polling cadence.
+ </p>
+ </div>
+ </div>
+ );
+}
+
+function SummaryCard({
+ label,
+ count,
+ level,
+}: {
+ label: string;
+ count: number;
+ level: StateCoverage["level"];
+}) {
+ return (
+ <Card className="p-5">
+ <p className="label mb-2">{label}</p>
+ <div className="flex items-baseline gap-2">
+ <span className="display text-3xl text-paper">{count}</span>
+ <span className="label text-ash-2">states</span>
+ </div>
+ <span
+ className={clsx("mt-3 inline-block h-0.5 w-12 rounded-full")}
+ style={{ backgroundColor: levelColor(level) }}
+ aria-hidden="true"
+ />
+ </Card>
+ );
+}
+
+function Th({
+ children,
+ className = "",
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
+}
+
+function Td({
+ children,
+ className = "",
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
+}
app/who-pays/page.tsx +76 -0
@@ -0,0 +1,76 @@
+import type { Metadata } from "next";
+import { Card, PageHeader, SectionHeader } from "@/components/ui";
+
+export const metadata: Metadata = {
+ title: "Who pays",
+ description:
+ "Ownership and funding of Docket. A trust surface - public, factual, no adjectives.",
+};
+
+export default function WhoPaysPage() {
+ return (
+ <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
+ <PageHeader
+ eyebrow="Trust · ownership"
+ title="Who pays for this"
+ lead="Docket is a trust product. The funding and ownership model is stated here in fact, and updated when it changes."
+ />
+
+ <div className="mt-10 grid gap-4 sm:grid-cols-3">
+ <Card className="p-5">
+ <p className="label mb-2">Ownership</p>
+ <p className="font-mono text-[13px] text-paper">Independent</p>
+ <p className="label mt-2 text-ash-2">No parent company on file</p>
+ </Card>
+ <Card className="p-5">
+ <p className="label mb-2">Revenue</p>
+ <p className="font-mono text-[13px] text-paper">None at present</p>
+ <p className="label mt-2 text-ash-2">No ads, no paid placement</p>
+ </Card>
+ <Card className="p-5">
+ <p className="label mb-2">Costs</p>
+ <p className="font-mono text-[13px] text-paper">Self-funded</p>
+ <p className="label mt-2 text-ash-2">Primary data is free; compute is the main cost</p>
+ </Card>
+ </div>
+
+ <div className="mt-10">
+ <SectionHeader
+ eyebrow="Data"
+ title="Where the records come from"
+ description="All primary data is free and official. The one paid source is OpenStates for state coverage."
+ />
+ <Card className="p-5">
+ <ul className="grid gap-2 font-mono text-[12px] text-ash sm:grid-cols-2">
+ <li>· Congress.gov - bills, actions, members, votes</li>
+ <li>· GovInfo - full bill text, Congressional Record, public laws</li>
+ <li>· USAspending.gov - awards and outlays</li>
+ <li>· Treasury Fiscal Data - debt, deficit, cash flows</li>
+ <li>· House Clerk and Senate - roll-call votes (XML)</li>
+ <li>· CBO - cost estimates</li>
+ <li>· OpenStates - 50-state + DC/PR coverage (paid tier)</li>
+ </ul>
+ </Card>
+ </div>
+
+ <div className="mt-10">
+ <SectionHeader eyebrow="Guardrails" title="What we will not take" />
+ <Card className="p-5">
+ <ul className="grid gap-2 font-mono text-[12px] text-ash">
+ <li>· Political-campaign or advocacy advertising, of any party.</li>
+ <li>· Paid placement in the register.</li>
+ <li>· Funding conditioned on surfacing or suppressing an item.</li>
+ </ul>
+ </Card>
+ </div>
+
+ <div className="mt-10">
+ <SectionHeader
+ eyebrow="Governance"
+ title="Review"
+ description="A bipartisan advisory board and a published neutrality audit are tracked as standing work."
+ />
+ </div>
+ </div>
+ );
+}
components/custody-mark.tsx +126 -0
@@ -0,0 +1,126 @@
+import { clsx } from "@/lib/cx";
+
+/**
+ * The Docket custody-stamp seal - a registration frame (outer rule + inner
+ * offset rule) around a [D] monogram with a small brass record-tick.
+ *
+ * Rules (docs/BRAND.md §3): FLAT strokes, no gradient, NO eagle / flag /
+ * dome / scales (those carry political valence). Below 24px drop the inner
+ * rule and show a solid tile + ink "D".
+ *
+ * The brass tick is brand identity, not a "signal" - the rationed per-view
+ * brass signal is the pulsing live-dot (see RegisterHero).
+ */
+export function CustodyMark({
+ size = 28,
+ className,
+ showTick = true,
+}: {
+ size?: number;
+ className?: string;
+ showTick?: boolean;
+}) {
+ // Below 24px → favicon-style solid tile, ink "D", no inner rule.
+ if (size < 24) {
+ return (
+ <span
+ className={clsx(
+ "inline-flex items-center justify-center rounded-[3px] font-display font-800",
+ className
+ )}
+ style={{
+ width: size,
+ height: size,
+ backgroundColor: "var(--dk-signal)",
+ color: "var(--dk-ink)",
+ fontSize: size * 0.62,
+ lineHeight: 1,
+ fontWeight: 800,
+ }}
+ aria-hidden="true"
+ >
+ D
+ </span>
+ );
+ }
+
+ // Full seal: outer rule + inner offset rule + D monogram + brass tick.
+ const stroke = Math.max(1, Math.round(size * 0.05));
+ return (
+ <span
+ className={clsx("inline-block align-middle", className)}
+ style={{ width: size, height: size }}
+ role="img"
+ aria-label="Docket seal"
+ >
+ <svg
+ width={size}
+ height={size}
+ viewBox="0 0 100 100"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ aria-hidden="true"
+ >
+ {/* outer rule */}
+ <rect
+ x="3"
+ y="3"
+ width="94"
+ height="94"
+ rx="6"
+ stroke="var(--dk-paper)"
+ strokeWidth={stroke}
+ opacity="0.92"
+ />
+ {/* inner offset rule */}
+ <rect
+ x="11"
+ y="11"
+ width="78"
+ height="78"
+ rx="3"
+ stroke="var(--dk-paper)"
+ strokeWidth={stroke}
+ opacity="0.32"
+ />
+ {/* [D] monogram */}
+ <text
+ x="50"
+ y="54"
+ textAnchor="middle"
+ dominantBaseline="central"
+ fontFamily="var(--font-display), system-ui, sans-serif"
+ fontWeight={800}
+ fontSize="52"
+ letterSpacing="-2"
+ fill="var(--dk-paper)"
+ >
+ D
+ </text>
+ {/* brass record-tick (the brand's only chromatic mark) */}
+ {showTick && (
+ <rect
+ x="72"
+ y="72"
+ width="14"
+ height="14"
+ rx="1"
+ fill="var(--dk-signal)"
+ />
+ )}
+ </svg>
+ </span>
+ );
+}
+
+/** Inline wordmark: "Docket" in Archivo 800, sentence case (BRAND.md §2). */
+export function Wordmark({ className }: { className?: string }) {
+ return (
+ <span
+ className={clsx("font-display", className)}
+ style={{ fontWeight: 800, letterSpacing: "-0.02em", lineHeight: 1 }}
+ >
+ Docket
+ </span>
+ );
+}
components/party-split-bar.tsx +60 -0
@@ -0,0 +1,60 @@
+import { partyToken, type PartyCode } from "@/lib/neutrality";
+import { clsx } from "@/lib/cx";
+
+export type PartySlice = { party: PartyCode; count: number };
+
+/**
+ * Sponsorship / cosponsorship split by party - muted, equal-weight tones,
+ * displayed in fixed party-alphabetical order (D, I, R) rather than by
+ * majority, so the chart never "picks a winner." (neutrality §Viz)
+ */
+export function PartySplitBar({
+ slices,
+ className,
+ ariaLabel,
+}: {
+ slices: PartySlice[];
+ className?: string;
+ ariaLabel: string;
+}) {
+ const order: PartyCode[] = ["D", "I", "R"];
+ const sorted = [...slices].sort(
+ (a, b) => order.indexOf(a.party) - order.indexOf(b.party)
+ );
+ const total = Math.max(1, sorted.reduce((n, s) => n + s.count, 0));
+
+ return (
+ <div className={clsx("w-full", className)}>
+ <div
+ className="flex h-2 w-full overflow-hidden rounded-[2px] border border-line"
+ role="img"
+ aria-label={ariaLabel}
+ >
+ {sorted.map((s) => (
+ <span
+ key={s.party}
+ style={{
+ width: `${(s.count / total) * 100}%`,
+ backgroundColor: partyToken(s.party),
+ }}
+ />
+ ))}
+ </div>
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-[12px]">
+ {sorted.map((s) => (
+ <span key={s.party} className="inline-flex items-center gap-1.5">
+ <span
+ aria-hidden="true"
+ className="inline-block h-1.5 w-1.5 rounded-full"
+ style={{ backgroundColor: partyToken(s.party) }}
+ />
+ <span className="label">
+ {s.party === "D" ? "Dem." : s.party === "R" ? "Rep." : "Ind."}
+ </span>
+ <span className="tabular-nums text-paper">{s.count}</span>
+ </span>
+ ))}
+ </div>
+ </div>
+ );
+}
components/party-tag.tsx +29 -0
@@ -0,0 +1,29 @@
+import { clsx } from "@/lib/cx";
+import { partyToken, type PartyCode } from "@/lib/neutrality";
+
+/**
+ * Party affiliation tag - factual data, shown in desaturated, equal-weight
+ * tones (neutrality §Viz). Never saturated red vs blue.
+ */
+export function PartyTag({
+ party,
+ className,
+}: {
+ party: PartyCode;
+ className?: string;
+}) {
+ const color = partyToken(party);
+ const name = party === "D" ? "Dem." : party === "R" ? "Rep." : "Ind.";
+ return (
+ <span
+ className={clsx("pill", className)}
+ style={{
+ color,
+ borderColor: `color-mix(in srgb, ${color} 40%, transparent)`,
+ }}
+ title={party === "D" ? "Democratic" : party === "R" ? "Republican" : "Independent"}
+ >
+ {name}
+ </span>
+ );
+}
components/register-hero.tsx +186 -0
@@ -0,0 +1,186 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import {
+ REGISTER_ACTIONS,
+ type RegisterAction,
+} from "@/lib/mock-data";
+import { SourceLink } from "@/components/source-link";
+import { clsx } from "@/lib/cx";
+
+/**
+ * THE REGISTER - Docket's signature hero (docs/BRAND.md §3).
+ * A real-time stamped ledger of official actions materializing line by line:
+ * mono rows, hairline separators, ONE pulsing brass live-dot at the top.
+ *
+ * The live-dot is the single rationed brass signal on /register.
+ *
+ * This is a static shell: rows are mocked. A client-side timer prepends a
+ * synthetic "just-sealed" row every several seconds so the ledger reads as
+ * live - no real data is fetched in Phase 0.
+ */
+export function RegisterHero() {
+ const [rows, setRows] = useState<RegisterAction[]>(REGISTER_ACTIONS);
+ const [flashId, setFlashId] = useState<string | null>(
+ REGISTER_ACTIONS[0]?.id ?? null
+ );
+ const tick = useRef(0);
+
+ useEffect(() => {
+ // Simulate the next action being sealed to the record.
+ const iv = setInterval(() => {
+ // Pull a rotating mock "next" action to avoid duplicating ids.
+ tick.current += 1;
+ const base =
+ REGISTER_ACTIONS[tick.current % REGISTER_ACTIONS.length];
+ const next: RegisterAction = {
+ ...base,
+ id: `${base.id}-t${tick.current}`,
+ };
+ setRows((prev) => [next, ...prev].slice(0, 9));
+ setFlashId(next.id);
+ }, 6000);
+ return () => clearInterval(iv);
+ }, []);
+
+ return (
+ <section
+ className="relative overflow-hidden border-y border-line"
+ aria-label="The Register - live feed of official actions"
+ >
+ {/* rationed liquid-glass brass wash (hero only) */}
+ <div
+ aria-hidden="true"
+ className="pointer-events-none absolute inset-0"
+ style={{ background: "var(--dk-hero-glow)" }}
+ />
+
+ <div className="relative mx-auto w-full max-w-register px-4 py-10 sm:px-6 sm:py-14">
+ {/* hero heading + the rationed live-dot */}
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
+ <div>
+ <div className="mb-3 flex items-center gap-2.5">
+ {/* ONE brass signal per view - the pulsing live-dot */}
+ <span className="live-dot animate-pulseDot" aria-hidden="true" />
+ <span className="label text-paper">Live · The Register</span>
+ </div>
+ <h1 className="display max-w-2xl text-4xl text-paper sm:text-5xl">
+ What the government did this week
+ </h1>
+ </div>
+ <p className="prose-lead max-w-sm text-ash">
+ Each line is an official action, sealed to its source. No
+ commentary. Order is the order of action.
+ </p>
+ </div>
+
+ {/* the ledger */}
+ <div
+ className="mt-8 overflow-hidden rounded-[3px] border border-line bg-surface/60 backdrop-blur-sm"
+ role="table"
+ aria-label="Stamped ledger of official actions"
+ >
+ <div
+ className="reg-row grid-cols-[5.5rem_6rem_minmax(0,1fr)_7.5rem_minmax(0,1fr)] px-3 py-2 sm:grid sm:px-4"
+ role="row"
+ >
+ <span className="label">Time</span>
+ <span className="label">Body</span>
+ <span className="label">Item</span>
+ <span className="label">Action</span>
+ <span className="label text-right">Result</span>
+ </div>
+
+ {rows.map((r) => {
+ const isNew = r.id === flashId;
+ const content = (
+ <RegisterRowInner row={r} flash={isNew} />
+ );
+ return (
+ <div key={r.id} role="row">
+ {r.billHref ? (
+ <Link
+ href={r.billHref}
+ className="focus-ring reg-row group grid-cols-[5.5rem_6rem_minmax(0,1fr)_7.5rem_minmax(0,1fr)] grid px-3 py-2.5 sm:px-4"
+ >
+ {content}
+ </Link>
+ ) : (
+ <div className="reg-row grid-cols-[5.5rem_6rem_minmax(0,1fr)_7.5rem_minmax(0,1fr)] grid px-3 py-2.5 sm:px-4">
+ {content}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+
+ <div className="mt-3 flex items-center justify-between">
+ <p className="label text-ash-2">
+ Ranked by action date, dollar magnitude, and people affected - never by coverage.
+ </p>
+ <Link
+ href="/how-we-pick"
+ className="focus-ring label text-ash underline-offset-4 hover:text-paper hover:underline"
+ >
+ How we pick →
+ </Link>
+ </div>
+ </div>
+ </section>
+ );
+}
+
+function RegisterRowInner({
+ row,
+ flash,
+}: {
+ row: RegisterAction;
+ flash?: boolean;
+}) {
+ const isFail = row.actionType === "FAILED" || row.actionType === "CLOTURE FAIL";
+ const isPass =
+ row.actionType === "PASSED" ||
+ row.actionType === "CLOTURE OK" ||
+ row.actionType === "SIGNED" ||
+ row.actionType === "REPORTED";
+ return (
+ <>
+ <span
+ className={clsx(
+ "font-mono text-[12px] tabular-nums text-ash",
+ flash && "animate-fadeUp"
+ )}
+ >
+ {row.time}
+ </span>
+ <span className="font-mono text-[12px] uppercase tracking-wide text-ash-2">
+ {row.chamber}
+ </span>
+ <span className="font-mono text-[13px] text-paper">
+ {row.label}
+ <SourceLink
+ url={row.sourceUrl}
+ label=""
+ className="ml-2 !inline-flex !py-0 !px-1 opacity-60 group-hover:opacity-100"
+ />
+ </span>
+ <span
+ className="font-mono text-[11px] uppercase tracking-wider"
+ style={{
+ color: isPass
+ ? "var(--dk-yea)"
+ : isFail
+ ? "var(--dk-nay)"
+ : "var(--dk-ash)",
+ }}
+ >
+ {row.actionType}
+ </span>
+ <span className="mt-1 font-mono text-[12px] tabular-nums text-paper sm:mt-0 sm:text-right">
+ {row.result}
+ </span>
+ </>
+ );
+}
components/site-footer.tsx +59 -0
@@ -0,0 +1,59 @@
+import Link from "next/link";
+import { CustodyMark } from "@/components/custody-mark";
+
+export function SiteFooter() {
+ const year = new Date().getFullYear();
+ return (
+ <footer className="mt-24 border-t border-line">
+ <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
+ <div className="flex flex-col gap-8 sm:flex-row sm:items-start sm:justify-between">
+ <div className="max-w-sm">
+ <div className="flex items-center gap-2.5">
+ <CustodyMark size={26} showTick={false} />
+ <span className="font-display text-[15px] font-800 tracking-tighter">
+ Docket
+ </span>
+ </div>
+ <p className="prose-lead mt-4 italic text-ash">
+ Record the action, not the argument.
+ </p>
+ </div>
+
+ <nav aria-label="Footer" className="grid grid-cols-2 gap-x-10 gap-y-2">
+ <FooterLink href="/register">Register</FooterLink>
+ <FooterLink href="/spending">Spending</FooterLink>
+ <FooterLink href="/states">States</FooterLink>
+ <FooterLink href="/how-we-pick">How we pick</FooterLink>
+ <FooterLink href="/who-pays">Who pays</FooterLink>
+ </nav>
+ </div>
+
+ <div className="mt-10 flex flex-col gap-2 border-t border-line pt-6 sm:flex-row sm:items-center sm:justify-between">
+ <p className="label">
+ Phase 0 · static shell · mock data · {year}
+ </p>
+ <p className="label text-ash-2">
+ Every fact below links to an official government record.
+ </p>
+ </div>
+ </div>
+ </footer>
+ );
+}
+
+function FooterLink({
+ href,
+ children,
+}: {
+ href: string;
+ children: React.ReactNode;
+}) {
+ return (
+ <Link
+ href={href}
+ className="label w-fit text-ash transition-colors hover:text-paper"
+ >
+ {children}
+ </Link>
+ );
+}
components/site-header.tsx +85 -0
@@ -0,0 +1,85 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { CustodyMark, Wordmark } from "@/components/custody-mark";
+import { clsx } from "@/lib/cx";
+
+const NAV: Array<{ href: string; label: string }> = [
+ { href: "/register", label: "Register" },
+ { href: "/spending", label: "Spending" },
+ { href: "/states", label: "States" },
+ { href: "/how-we-pick", label: "How we pick" },
+ { href: "/who-pays", label: "Who pays" },
+];
+
+export function SiteHeader() {
+ const pathname = usePathname();
+
+ return (
+ <header className="sticky top-0 z-40 border-b border-line bg-ink/85 backdrop-blur-md">
+ <div className="mx-auto flex h-14 w-full max-w-register items-center justify-between gap-4 px-4 sm:px-6">
+ <Link
+ href="/register"
+ className="focus-ring flex items-center gap-2.5 rounded-sm"
+ aria-label="Docket - home"
+ >
+ <CustodyMark size={26} showTick={false} />
+ <Wordmark className="text-[15px]" />
+ </Link>
+
+ <nav aria-label="Primary" className="hidden md:block">
+ <ul className="flex items-center gap-1">
+ {NAV.map((item) => {
+ const active =
+ pathname === item.href || pathname?.startsWith(item.href + "/");
+ return (
+ <li key={item.href}>
+ <Link
+ href={item.href}
+ className={clsx(
+ "focus-ring label rounded-sm px-2.5 py-1.5 transition-colors",
+ active
+ ? "text-paper"
+ : "text-ash hover:text-paper"
+ )}
+ style={
+ active
+ ? { boxShadow: "inset 0 -1px 0 var(--dk-paper)" }
+ : undefined
+ }
+ aria-current={active ? "page" : undefined}
+ >
+ {item.label}
+ </Link>
+ </li>
+ );
+ })}
+ </ul>
+ </nav>
+
+ {/* Mobile nav: condensed to a select-like row of mono labels */}
+ <nav aria-label="Primary mobile" className="md:hidden">
+ <ul className="flex items-center gap-x-2 gap-y-1 overflow-x-auto">
+ {NAV.slice(0, 3).map((item) => {
+ const active = pathname === item.href;
+ return (
+ <li key={item.href}>
+ <Link
+ href={item.href}
+ className={clsx(
+ "label whitespace-nowrap py-1",
+ active ? "text-paper" : "text-ash"
+ )}
+ >
+ {item.label}
+ </Link>
+ </li>
+ );
+ })}
+ </ul>
+ </nav>
+ </div>
+ </header>
+ );
+}
components/source-link.tsx +48 -0
@@ -0,0 +1,48 @@
+import { clsx } from "@/lib/cx";
+import { isAllowedSourceHost } from "@/lib/neutrality";
+
+/**
+ * Visible "Source" link on every fact (neutrality rule #1).
+ * Hosts must be in the allowlist; otherwise we refuse to render the link
+ * as a "Source" and show a guard marker instead - the build-time lint also
+ * rejects these, so this is a defense-in-depth.
+ */
+export function SourceLink({
+ url,
+ label = "Source",
+ className,
+}: {
+ url: string;
+ label?: string;
+ className?: string;
+}) {
+ const allowed = isAllowedSourceHost(url);
+ const host = safeHost(url);
+ return (
+ <a
+ href={url}
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ className={clsx(
+ "focus-ring label inline-flex items-center gap-1 rounded-sm border border-line px-1.5 py-0.5 transition-colors",
+ allowed
+ ? "text-ash hover:text-paper hover:border-paper"
+ : "text-nay",
+ className
+ )}
+ title={allowed ? `Official record · ${host}` : "Source host not allowlisted"}
+ >
+ <span aria-hidden="true">↗</span>
+ <span>{label}</span>
+ {allowed && host ? <span className="text-ash-2">· {host}</span> : null}
+ </a>
+ );
+}
+
+function safeHost(url: string): string {
+ try {
+ return new URL(url).host.replace(/^www\./, "");
+ } catch {
+ return "";
+ }
+}
components/status-pill.tsx +57 -0
@@ -0,0 +1,57 @@
+import { clsx } from "@/lib/cx";
+
+export type StatusVariant = "passed" | "failed" | "held" | "pending" | "neutral";
+
+const VARIANT_TOKEN: Record<StatusVariant, { color: string; label?: string }> = {
+ // muted semantic tones only - never vivid (neutrality §Viz)
+ passed: { color: "var(--dk-yea)" },
+ failed: { color: "var(--dk-nay)" },
+ held: { color: "var(--dk-hold)" },
+ pending: { color: "var(--dk-hold)" },
+ neutral: { color: "var(--dk-ash)" },
+};
+
+/**
+ * Mono status pill. Color comes from the muted semantic tokens.
+ * No brass here - brass is rationed to one signal per view (the live-dot).
+ */
+export function StatusPill({
+ variant = "neutral",
+ children,
+ className,
+ dot = false,
+}: {
+ variant?: StatusVariant;
+ children: React.ReactNode;
+ className?: string;
+ dot?: boolean;
+}) {
+ const { color } = VARIANT_TOKEN[variant];
+ return (
+ <span
+ className={clsx("pill", className)}
+ style={{ color, borderColor: `color-mix(in srgb, ${color} 38%, transparent)` }}
+ >
+ {dot ? (
+ <span
+ aria-hidden="true"
+ className="inline-block h-1.5 w-1.5 rounded-full"
+ style={{ backgroundColor: color }}
+ />
+ ) : null}
+ {children}
+ </span>
+ );
+}
+
+/** Classify a raw status string into a muted variant. */
+export function classifyStatus(raw: string): StatusVariant {
+ const s = raw.toLowerCase();
+ if (/\b(pass|passed|signed|enacted|became law|cloture ok|agreed)\b/.test(s))
+ return "passed";
+ if (/\b(fail|failed|rejected|veto|not passed|cloture fail)\b/.test(s))
+ return "failed";
+ if (/\b(stall|held|in committee|referred|table)\b/.test(s)) return "held";
+ if (/\b(pending|scheduled|introduced|await)\b/.test(s)) return "pending";
+ return "neutral";
+}
components/ui.tsx +73 -0
@@ -0,0 +1,73 @@
+import { clsx } from "@/lib/cx";
+
+/** A surface card: --dk-surface on --dk-ink, hairline border, hover lift. */
+export function Card({
+ as: Tag = "div",
+ className,
+ hover = false,
+ children,
+ ...rest
+}: {
+ as?: React.ElementType;
+ className?: string;
+ hover?: boolean;
+ children: React.ReactNode;
+ [key: string]: unknown;
+}) {
+ return (
+ <Tag
+ className={clsx(
+ "rounded-[3px] border border-line bg-surface",
+ hover && "transition-colors hover:bg-graphite",
+ className
+ )}
+ {...rest}
+ >
+ {children}
+ </Tag>
+ );
+}
+
+/** Page section header - mono label + optional display headline. */
+export function SectionHeader({
+ eyebrow,
+ title,
+ description,
+ className,
+}: {
+ eyebrow?: string;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <div className={clsx("mb-6", className)}>
+ {eyebrow ? <p className="label mb-2">{eyebrow}</p> : null}
+ {title ? (
+ <h2 className="display text-2xl text-paper sm:text-3xl">{title}</h2>
+ ) : null}
+ {description ? (
+ <p className="prose-lead mt-3 max-w-2xl text-ash">{description}</p>
+ ) : null}
+ </div>
+ );
+}
+
+/** Page header block used at the top of each route. */
+export function PageHeader({
+ eyebrow,
+ title,
+ lead,
+}: {
+ eyebrow: string;
+ title: string;
+ lead?: string;
+}) {
+ return (
+ <header className="border-b border-line pb-8">
+ <p className="label mb-3">{eyebrow}</p>
+ <h1 className="display text-4xl text-paper sm:text-5xl">{title}</h1>
+ {lead ? <p className="prose-lead mt-4 max-w-2xl text-ash">{lead}</p> : null}
+ </header>
+ );
+}
components/vote-tally.tsx +102 -0
@@ -0,0 +1,102 @@
+import { clsx } from "@/lib/cx";
+
+/**
+ * Vote tally - muted yea / nay visualization (neutrality §Viz).
+ * Yea uses --dk-yea (muted sage), Nay uses --dk-nay (muted clay).
+ * Equal visual weight; a thin hold/present sliver is rendered in ash when
+ * present > 0. Bars are proportional, never oversaturated.
+ *
+ * Default ordering: yea then nay then present, by-count (not by party) -
+ * grouping by party primes partisan reading and is forbidden.
+ */
+export function VoteTally({
+ yea,
+ nay,
+ present = 0,
+ result,
+ className,
+}: {
+ yea: number;
+ nay: number;
+ present?: number;
+ result?: "passed" | "failed";
+ className?: string;
+}) {
+ const total = Math.max(1, yea + nay + present);
+ const yeaPct = (yea / total) * 100;
+ const nayPct = (nay / total) * 100;
+ const presentPct = (present / total) * 100;
+
+ return (
+ <div className={clsx("w-full", className)}>
+ <div className="mb-2 flex items-baseline justify-between font-mono">
+ <span className="label">Tally</span>
+ {result ? (
+ <span
+ className="label"
+ style={{
+ color:
+ result === "passed"
+ ? "var(--dk-yea)"
+ : "var(--dk-nay)",
+ }}
+ >
+ {result === "passed" ? "Passed" : "Failed"}
+ </span>
+ ) : null}
+ </div>
+ <div
+ className="flex h-2.5 w-full overflow-hidden rounded-[2px] border border-line"
+ role="img"
+ aria-label={`Vote tally: ${yea} yea, ${nay} nay${present ? `, ${present} present` : ""}`}
+ >
+ <span style={{ width: `${yeaPct}%`, backgroundColor: "var(--dk-yea)" }} />
+ <span style={{ width: `${nayPct}%`, backgroundColor: "var(--dk-nay)" }} />
+ {present > 0 ? (
+ <span style={{ width: `${presentPct}%`, backgroundColor: "var(--dk-ash-2)" }} />
+ ) : null}
+ </div>
+ <div className="mt-2 grid grid-cols-3 gap-2 font-mono text-[13px]">
+ <TallyCell label="Yea" value={yea} color="var(--dk-yea)" />
+ <TallyCell label="Nay" value={nay} color="var(--dk-nay)" />
+ <TallyCell
+ label="Present"
+ value={present}
+ color="var(--dk-ash-2)"
+ dim={present === 0}
+ />
+ </div>
+ </div>
+ );
+}
+
+function TallyCell({
+ label,
+ value,
+ color,
+ dim,
+}: {
+ label: string;
+ value: number;
+ color: string;
+ dim?: boolean;
+}) {
+ return (
+ <div className="border-l border-line pl-2">
+ <div className="label flex items-center gap-1.5" style={{ color: dim ? "var(--dk-ash-2)" : color }}>
+ <span
+ aria-hidden="true"
+ className="inline-block h-1.5 w-1.5 rounded-full"
+ style={{ backgroundColor: color }}
+ />
+ {label}
+ </div>
+ <div
+ className="mt-0.5 tabular-nums"
+ style={{ color: dim ? "var(--dk-ash-2)" : "var(--dk-paper)" }}
+ >
+ {value}
+ </div>
+ </div>
+ );
+}
lib/cx.ts +6 -0
@@ -0,0 +1,6 @@
+/** Tiny className combiner (no dependency on clsx). */
+export function clsx(
+ ...parts: Array<string | false | null | undefined>
+): string {
+ return parts.filter(Boolean).join(" ");
+}
lib/mock-data.ts +446 -0
@@ -0,0 +1,446 @@
+/**
+ * MOCK DATA - Phase 0 static shell. No real fetching. Every record carries
+ * a sourceUrl pointing to an allowlisted government host (neutrality rule #1).
+ *
+ * Copy is factual only (neutrality rule #2): numbers, status, dates, names.
+ * No adjectives. A build-time lint (lint/neutrality.mjs) checks this file.
+ *
+ * All timestamps are ET. Bill numbers follow Congress.gov conventions.
+ */
+
+import type { PartyCode } from "@/lib/neutrality";
+
+export type Chamber = "HOUSE" | "SENATE" | "USASPENDING" | "TREASURY" | "CBO";
+
+export type RegisterAction = {
+ id: string;
+ time: string; // ET wall-clock, e.g. "14:02 ET"
+ iso: string; // ISO 8601 for ordering
+ chamber: Chamber;
+ label: string; // H.R. 22 / S. 1582 / DoD
+ actionType:
+ | "FAILED"
+ | "PASSED"
+ | "CLOTURE OK"
+ | "CLOTURE FAIL"
+ | "INTRODUCED"
+ | "REPORTED"
+ | "SIGNED"
+ | "AWARD"
+ | "OUTLAY"
+ | "DEBT";
+ result: string; // 209-214 / $2.1B / 62-38
+ billHref?: string; // internal bill page
+ sourceUrl: string;
+ amountUsd?: number; // for ranking (rule #3 inputs)
+ affectedEstimate?: number;
+};
+
+export type TimelineEntry = {
+ date: string; // ISO date
+ label: string; // factual action verb only
+ chamber?: Chamber;
+ sourceUrl: string;
+};
+
+export type Bill = {
+ id: string; // slug, e.g. "hr-22-119"
+ number: string; // H.R. 22
+ congress: number;
+ title: string; // official title only
+ sponsor: { name: string; party: PartyCode; state: string; sourceUrl: string };
+ policyArea: string;
+ introducedDate: string;
+ status: string; // raw status string
+ cbo?: { amountUsd: number; sourceUrl: string };
+ votes: Array<{
+ id: string;
+ chamber: "HOUSE" | "SENATE";
+ date: string;
+ question: string;
+ yea: number;
+ nay: number;
+ present: number;
+ result: "passed" | "failed";
+ sourceUrl: string;
+ }>;
+ cosponsors: PartyCode[]; // for the party-split viz
+ timeline: TimelineEntry[];
+ sourceUrl: string;
+};
+
+export type Award = {
+ id: string;
+ agency: string;
+ recipient: string;
+ amountUsd: number;
+ actionDate: string;
+ program: string;
+ sourceUrl: string;
+};
+
+export type FiscalMetric = {
+ id: string;
+ metric: string; // "Total public debt outstanding"
+ valueUsd: number;
+ asOf: string;
+ deltaLabel: string; // factual delta, no adjective
+ sourceUrl: string;
+};
+
+export type StateCoverage = {
+ code: string;
+ name: string;
+ level: "full" | "partial" | "lagging";
+ lastSeen: string; // ISO
+ billsThisWeek: number;
+ sourceUrl: string;
+};
+
+/* ------------------------------------------------------------------ */
+/* REGISTER FEED */
+/* ------------------------------------------------------------------ */
+
+export const REGISTER_ACTIONS: RegisterAction[] = [
+ {
+ id: "a1",
+ time: "14:02 ET",
+ iso: "2026-06-26T18:02:00Z",
+ chamber: "HOUSE",
+ label: "H.R. 22",
+ actionType: "FAILED",
+ result: "209-214",
+ billHref: "/bill/hr-22-119",
+ sourceUrl: "https://clerk.house.gov/evs/2026/roll300.xml",
+ affectedEstimate: 0,
+ },
+ {
+ id: "a2",
+ time: "13:47 ET",
+ iso: "2026-06-26T17:47:00Z",
+ chamber: "SENATE",
+ label: "S. 1582",
+ actionType: "CLOTURE OK",
+ result: "62-38",
+ billHref: "/bill/s-1582-119",
+ sourceUrl: "https://www.senate.gov/legislative/LIS/roll_call_votes/vote1192/vote_119_2_00150.htm",
+ },
+ {
+ id: "a3",
+ time: "13:31 ET",
+ iso: "2026-06-26T17:31:00Z",
+ chamber: "USASPENDING",
+ label: "DoD",
+ actionType: "AWARD",
+ result: "$2,100,000,000",
+ amountUsd: 2_100_000_000,
+ sourceUrl: "https://www.usaspending.gov/award/ASST_NON_AWARD_FAKE_001",
+ },
+ {
+ id: "a4",
+ time: "12:58 ET",
+ iso: "2026-06-26T16:58:00Z",
+ chamber: "HOUSE",
+ label: "H.R. 3401",
+ actionType: "REPORTED",
+ result: "Committee",
+ billHref: "/bill/hr-3401-119",
+ sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/3401/actions",
+ },
+ {
+ id: "a5",
+ time: "11:40 ET",
+ iso: "2026-06-26T15:40:00Z",
+ chamber: "TREASURY",
+ label: "DTS",
+ actionType: "OUTLAY",
+ result: "$184.2B",
+ amountUsd: 184_200_000_000,
+ sourceUrl: "https://fiscaldata.treasury.gov/datasets/daily-treasury-statement/operating-cash-balance",
+ },
+ {
+ id: "a6",
+ time: "10:12 ET",
+ iso: "2026-06-26T14:12:00Z",
+ chamber: "SENATE",
+ label: "S. 2044",
+ actionType: "INTRODUCED",
+ result: "Read twice",
+ billHref: "/bill/s-2044-119",
+ sourceUrl: "https://www.congress.gov/bill/119th-congress/senate-bill/2044",
+ },
+ {
+ id: "a7",
+ time: "09:33 ET",
+ iso: "2026-06-26T13:33:00Z",
+ chamber: "USASPENDING",
+ label: "HHS",
+ actionType: "AWARD",
+ result: "$612,000,000",
+ amountUsd: 612_000_000,
+ sourceUrl: "https://www.usaspending.gov/award/ASST_NON_AWARD_FAKE_002",
+ },
+ {
+ id: "a8",
+ time: "08:05 ET",
+ iso: "2026-06-26T12:05:00Z",
+ chamber: "HOUSE",
+ label: "H.R. 22",
+ actionType: "PASSED",
+ result: "Motion agreed",
+ billHref: "/bill/hr-22-119",
+ sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/22/actions",
+ },
+];
+
+/* ------------------------------------------------------------------ */
+/* BILLS */
+/* ------------------------------------------------------------------ */
+
+export const BILLS: Bill[] = [
+ {
+ id: "hr-22-119",
+ number: "H.R. 22",
+ congress: 119,
+ title: "To amend title 23, United States Code, regarding surface transportation reauthorization",
+ sponsor: {
+ name: "A. Marshall",
+ party: "R",
+ state: "TX",
+ sourceUrl: "https://www.congress.gov/member/a-marshall/M001200",
+ },
+ policyArea: "Transportation",
+ introducedDate: "2026-01-07",
+ status: "Failed passage in House, 209-214",
+ cbo: { amountUsd: 48_200_000_000, sourceUrl: "https://www.cbo.gov/publication/59001" },
+ votes: [
+ {
+ id: "v1",
+ chamber: "HOUSE",
+ date: "2026-06-26",
+ question: "On passage",
+ yea: 209,
+ nay: 214,
+ present: 4,
+ result: "failed",
+ sourceUrl: "https://clerk.house.gov/evs/2026/roll300.xml",
+ },
+ ],
+ cosponsors: ["R", "R", "R", "D", "D", "R", "I", "D", "R", "R", "D", "R"],
+ timeline: [
+ { date: "2026-01-07", label: "Introduced", chamber: "HOUSE", sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/22" },
+ { date: "2026-02-19", label: "Referred to Committee on Transportation", chamber: "HOUSE", sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/22/actions" },
+ { date: "2026-04-03", label: "Reported out of Committee", chamber: "HOUSE", sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/22/actions" },
+ { date: "2026-06-26", label: "Failed passage, 209-214", chamber: "HOUSE", sourceUrl: "https://clerk.house.gov/evs/2026/roll300.xml" },
+ ],
+ sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/22",
+ },
+ {
+ id: "s-1582-119",
+ number: "S. 1582",
+ congress: 119,
+ title: "A bill to provide for continuing appropriations for fiscal year 2026",
+ sponsor: {
+ name: "P. Okafor",
+ party: "D",
+ state: "MI",
+ sourceUrl: "https://www.congress.gov/member/p-okafor/S001200",
+ },
+ policyArea: "Appropriations",
+ introducedDate: "2026-03-14",
+ status: "Cloture invoked, 62-38",
+ votes: [
+ {
+ id: "v2",
+ chamber: "SENATE",
+ date: "2026-06-26",
+ question: "On the cloture motion",
+ yea: 62,
+ nay: 38,
+ present: 0,
+ result: "passed",
+ sourceUrl: "https://www.senate.gov/legislative/LIS/roll_call_votes/vote1192/vote_119_2_00150.htm",
+ },
+ ],
+ cosponsors: ["D", "D", "I", "D", "R", "D", "D", "R", "D"],
+ timeline: [
+ { date: "2026-03-14", label: "Introduced", chamber: "SENATE", sourceUrl: "https://www.congress.gov/bill/119th-congress/senate-bill/1582" },
+ { date: "2026-04-22", label: "Referred to Committee on Appropriations", chamber: "SENATE", sourceUrl: "https://www.congress.gov/bill/119th-congress/senate-bill/1582/actions" },
+ { date: "2026-06-26", label: "Cloture invoked, 62-38", chamber: "SENATE", sourceUrl: "https://www.senate.gov/legislative/LIS/roll_call_votes/vote1192/vote_119_2_00150.htm" },
+ ],
+ sourceUrl: "https://www.congress.gov/bill/119th-congress/senate-bill/1582",
+ },
+ {
+ id: "hr-3401-119",
+ number: "H.R. 3401",
+ congress: 119,
+ title: "To amend the Internal Revenue Code of 1986 regarding treatment of certain research expenditures",
+ sponsor: {
+ name: "L. Brennan",
+ party: "D",
+ state: "WA",
+ sourceUrl: "https://www.congress.gov/member/l-brennan/M001300",
+ },
+ policyArea: "Taxation",
+ introducedDate: "2026-02-02",
+ status: "Reported out of Committee",
+ cbo: { amountUsd: 9_700_000_000, sourceUrl: "https://www.cbo.gov/publication/59042" },
+ votes: [],
+ cosponsors: ["D", "D", "R", "R", "D", "D", "R"],
+ timeline: [
+ { date: "2026-02-02", label: "Introduced", chamber: "HOUSE", sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/3401" },
+ { date: "2026-03-30", label: "Referred to Committee on Ways and Means", chamber: "HOUSE", sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/3401/actions" },
+ { date: "2026-06-26", label: "Reported out of Committee", chamber: "HOUSE", sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/3401/actions" },
+ ],
+ sourceUrl: "https://www.congress.gov/bill/119th-congress/house-bill/3401",
+ },
+ {
+ id: "s-2044-119",
+ number: "S. 2044",
+ congress: 119,
+ title: "A bill to authorize appropriations for the National Institute of Standards and Technology",
+ sponsor: {
+ name: "R. Vance",
+ party: "R",
+ state: "OH",
+ sourceUrl: "https://www.congress.gov/member/r-vance/S001300",
+ },
+ policyArea: "Science",
+ introducedDate: "2026-06-26",
+ status: "Introduced, read twice",
+ votes: [],
+ cosponsors: ["R", "D"],
+ timeline: [
+ { date: "2026-06-26", label: "Introduced, read twice", chamber: "SENATE", sourceUrl: "https://www.congress.gov/bill/119th-congress/senate-bill/2044" },
+ ],
+ sourceUrl: "https://www.congress.gov/bill/119th-congress/senate-bill/2044",
+ },
+];
+
+export function getBill(id: string): Bill | undefined {
+ return BILLS.find((b) => b.id === id);
+}
+
+/* ------------------------------------------------------------------ */
+/* SPENDING */
+/* ------------------------------------------------------------------ */
+
+export const AWARDS: Award[] = [
+ {
+ id: "w1",
+ agency: "Department of Defense",
+ recipient: "Aero Systems Holdings, Inc.",
+ amountUsd: 2_100_000_000,
+ actionDate: "2026-06-26",
+ program: "Aircraft procurement",
+ sourceUrl: "https://www.usaspending.gov/award/ASST_NON_AWARD_FAKE_001",
+ },
+ {
+ id: "w2",
+ agency: "Department of Health and Human Services",
+ recipient: "State of California - Medicaid",
+ amountUsd: 612_000_000,
+ actionDate: "2026-06-26",
+ program: "Federal share, Medicaid",
+ sourceUrl: "https://www.usaspending.gov/award/ASST_NON_AWARD_FAKE_002",
+ },
+ {
+ id: "w3",
+ agency: "Department of Transportation",
+ recipient: "Regional Transit Authority",
+ amountUsd: 284_000_000,
+ actionDate: "2026-06-25",
+ program: "Capital investment grants",
+ sourceUrl: "https://www.usaspending.gov/award/ASST_NON_AWARD_FAKE_003",
+ },
+ {
+ id: "w4",
+ agency: "Department of Energy",
+ recipient: "Grid Operations Cooperative",
+ amountUsd: 158_000_000,
+ actionDate: "2026-06-25",
+ program: "Grid resilience",
+ sourceUrl: "https://www.usaspending.gov/award/ASST_NON_AWARD_FAKE_004",
+ },
+ {
+ id: "w5",
+ agency: "General Services Administration",
+ recipient: "Civil Works LLC",
+ amountUsd: 92_000_000,
+ actionDate: "2026-06-24",
+ program: "Federal buildings",
+ sourceUrl: "https://www.usaspending.gov/award/ASST_NON_AWARD_FAKE_005",
+ },
+];
+
+export const FISCAL_METRICS: FiscalMetric[] = [
+ {
+ id: "f1",
+ metric: "Total public debt outstanding",
+ valueUsd: 36_400_000_000_000,
+ asOf: "2026-06-25",
+ deltaLabel: "+$48B over 30 days",
+ sourceUrl: "https://fiscaldata.treasury.gov/datasets/debt-to-the-penny/debt-to-the-penny",
+ },
+ {
+ id: "f2",
+ metric: "Federal outlays, month to date",
+ valueUsd: 184_200_000_000,
+ asOf: "2026-06-26",
+ deltaLabel: "Daily Treasury Statement",
+ sourceUrl: "https://fiscaldata.treasury.gov/datasets/daily-treasury-statement/operating-cash-balance",
+ },
+ {
+ id: "f3",
+ metric: "Operating cash balance",
+ valueUsd: 712_000_000_000,
+ asOf: "2026-06-26",
+ deltaLabel: "Treasury, T+1",
+ sourceUrl: "https://fiscaldata.treasury.gov/datasets/daily-treasury-statement/operating-cash-balance",
+ },
+];
+
+/* ------------------------------------------------------------------ */
+/* STATES */
+/* ------------------------------------------------------------------ */
+
+export const STATE_COVERAGE: StateCoverage[] = [
+ { code: "CA", name: "California", level: "full", lastSeen: "2026-06-26", billsThisWeek: 24, sourceUrl: "https://leginfo.legislature.ca.gov/" },
+ { code: "TX", name: "Texas", level: "full", lastSeen: "2026-06-26", billsThisWeek: 19, sourceUrl: "https://capitol.texas.gov/" },
+ { code: "NY", name: "New York", level: "full", lastSeen: "2026-06-25", billsThisWeek: 14, sourceUrl: "https://www.nysenate.gov/legislation" },
+ { code: "FL", name: "Florida", level: "partial", lastSeen: "2026-06-24", billsThisWeek: 9, sourceUrl: "https://www.flsenate.gov/Session/Bills" },
+ { code: "IL", name: "Illinois", level: "full", lastSeen: "2026-06-26", billsThisWeek: 11, sourceUrl: "https://www.ilga.gov/" },
+ { code: "PA", name: "Pennsylvania", level: "partial", lastSeen: "2026-06-23", billsThisWeek: 6, sourceUrl: "https://www.legis.state.pa.us/" },
+ { code: "OH", name: "Ohio", level: "full", lastSeen: "2026-06-26", billsThisWeek: 8, sourceUrl: "https://www.legislature.ohio.gov/" },
+ { code: "GA", name: "Georgia", level: "lagging", lastSeen: "2026-06-20", billsThisWeek: 2, sourceUrl: "https://www.legis.ga.gov/" },
+ { code: "NC", name: "North Carolina", level: "partial", lastSeen: "2026-06-22", billsThisWeek: 5, sourceUrl: "https://www.ncleg.gov/" },
+ { code: "MI", name: "Michigan", level: "full", lastSeen: "2026-06-26", billsThisWeek: 10, sourceUrl: "https://www.legislature.mi.gov/" },
+];
+
+/* ------------------------------------------------------------------ */
+/* FORMATTING HELPERS */
+/* ------------------------------------------------------------------ */
+
+export function formatUsd(n: number, opts?: { compact?: boolean }): string {
+ if (opts?.compact && Math.abs(n) >= 1_000_000_000) {
+ return `$${(n / 1_000_000_000).toFixed(1)}B`;
+ }
+ if (opts?.compact && Math.abs(n) >= 1_000_000) {
+ return `$${(n / 1_000_000).toFixed(0)}M`;
+ }
+ return n.toLocaleString("en-US", {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 0,
+ });
+}
+
+export function formatDate(iso: string): string {
+ const d = new Date(iso + (iso.length === 10 ? "T00:00:00Z" : ""));
+ return d.toLocaleDateString("en-US", {
+ timeZone: "UTC",
+ year: "numeric",
+ month: "short",
+ day: "2-digit",
+ });
+}
lib/neutrality.ts +110 -0
@@ -0,0 +1,110 @@
+/**
+ * Docket neutrality helpers - encode rules from docs/NEUTRALITY.md.
+ *
+ * Rule #1: government-source-only. Every fact's sourceUrl host must be in
+ * the allowlist below. The build-time lint (lint/neutrality.mjs) also checks
+ * generated copy and mock data; this is the runtime/component-level guard.
+ */
+
+const ALLOWED_SOURCE_HOSTS = [
+ "congress.gov",
+ "api.congress.gov",
+ "govinfo.gov",
+ "usaspending.gov",
+ "fiscaldata.treasury.gov",
+ "cbo.gov",
+ "gpo.gov",
+ "clerk.house.gov",
+ "senate.gov",
+ "openstates.org", // OpenStates itself, links onward to state .gov legislatures
+ "house.gov",
+ "whitehouse.gov",
+];
+
+/** True if url's host is an allowlisted government / official-record source. */
+export function isAllowedSourceHost(url: string): boolean {
+ let host: string;
+ try {
+ host = new URL(url).host.toLowerCase().replace(/^www\./, "");
+ } catch {
+ return false;
+ }
+ if (host.endsWith(".gov")) return true;
+ return ALLOWED_SOURCE_HOSTS.includes(host);
+}
+
+/**
+ * Rule #2: no qualitative adjectives in copy. The list lives in code and is
+ * extended over time. Used by the build-time lint; exposed here for any
+ * runtime path that displays contributor-supplied text.
+ */
+export const BANNED_WORDS = [
+ "slashed",
+ "gutted",
+ "slammed",
+ "blasted",
+ "massive",
+ "tiny",
+ "huge",
+ "sweeping",
+ "radical",
+ "extreme",
+ "controversial",
+ "popular",
+ "unpopular",
+ "landmark",
+ "historic",
+ "devastating",
+ "crippling",
+ "generous",
+ "stingy",
+ "bloated",
+ "wasteful",
+ "common-sense",
+ "partisan",
+ "crucial",
+ "vital",
+ "narrowly",
+ "barely",
+ "overwhelmingly",
+ "finally",
+ "surprisingly",
+ "key",
+];
+
+const BANNED_RE = new RegExp(
+ `\\b(${BANNED_WORDS.join("|")})\\b`,
+ "i"
+);
+
+/** Returns the first banned word found in text, or null if clean. */
+export function findBannedWord(text: string): string | null {
+ const m = BANNED_RE.exec(text);
+ return m ? m[1].toLowerCase() : null;
+}
+
+/**
+ * Rule #3: ranking references only these four inputs. The score function is
+ * pure and unit-tested to take ONLY this shape. Documented here as the
+ * contract; the real ranking lands in Phase 1.
+ */
+export type RankingInputs = {
+ actionDate: string; // ISO
+ dollarAmount: number | null;
+ affectedEstimate: number | null;
+ actionType: string;
+};
+
+export type PartyCode = "D" | "R" | "I";
+
+/** Muted party token for a party code (neutrality: equal weight). */
+export function partyToken(p: PartyCode): string {
+ switch (p) {
+ case "D":
+ return "var(--dk-party-d)";
+ case "R":
+ return "var(--dk-party-r)";
+ default:
+ return "var(--dk-party-i)";
+ }
+}
lint/neutrality.mjs +111 -0
@@ -0,0 +1,111 @@
+#!/usr/bin/env node
+/**
+ * Docket neutrality lint (docs/NEUTRALITY.md).
+ *
+ * Phase 0 scope:
+ * - Rule #2 (no adjectives): scan all string literals in mock data + page
+ * copy for the banned-words list.
+ * - Rule #1 (gov-source-only): scan lib/mock-data.ts sourceUrl values and
+ * reject any whose host is not allowlisted.
+ *
+ * Exits non-zero on any violation so it can gate `npm run build` later.
+ * For Phase 0 it is wired as `npm run neutrality` and prints a report.
+ */
+import fs from "node:fs";
+import path from "node:path";
+
+const ROOT = path.resolve(new URL("..", import.meta.url).pathname);
+
+const BANNED = [
+ "slashed", "gutted", "slammed", "blasted", "massive", "tiny", "huge",
+ "sweeping", "radical", "extreme", "controversial", "popular", "unpopular",
+ "landmark", "historic", "devastating", "crippling", "generous", "stingy",
+ "bloated", "wasteful", "common-sense", "partisan", "crucial", "vital",
+ "narrowly", "barely", "overwhelmingly", "finally", "surprisingly", "key",
+];
+
+const ALLOW_HOSTS = [
+ "congress.gov", "api.congress.gov", "govinfo.gov", "usaspending.gov",
+ "fiscaldata.treasury.gov", "cbo.gov", "gpo.gov", "clerk.house.gov",
+ "senate.gov", "openstates.org", "house.gov", "whitehouse.gov",
+];
+
+const ALLOW_HOST_RE = /(^|\.)gov$|\.gov$/i;
+
+const SCANS = [
+ "lib/mock-data.ts",
+ "app/register/page.tsx",
+ "app/bill/[id]/page.tsx",
+ "app/spending/page.tsx",
+ "app/states/page.tsx",
+ "app/how-we-pick/page.tsx",
+ "app/who-pays/page.tsx",
+ "components/site-footer.tsx",
+];
+
+let violations = 0;
+
+function checkAllowed(url, file) {
+ let host;
+ try {
+ host = new URL(url).host.toLowerCase().replace(/^www\./, "");
+ } catch {
+ violations++;
+ console.log(` ✗ ${file}: malformed sourceUrl "${url}"`);
+ return;
+ }
+ const ok = ALLOW_HOST_RE.test(host) || ALLOW_HOSTS.includes(host);
+ if (!ok) {
+ violations++;
+ console.log(` ✗ ${file}: source host "${host}" not allowlisted`);
+ }
+}
+
+console.log("Docket neutrality lint");
+console.log("----------------------");
+
+// Rule #2 - banned words across all copy-bearing files
+const bannedRe = new RegExp(`\\b(${BANNED.join("|")})\\b`, "gi");
+console.log("\n#2 no adjectives");
+for (const rel of SCANS) {
+ const file = path.join(ROOT, rel);
+ if (!fs.existsSync(file)) {
+ console.log(` - ${rel}: skipped (not found)`);
+ continue;
+ }
+ const text = fs.readFileSync(file, "utf8");
+ let m;
+ while ((m = bannedRe.exec(text))) {
+ // Allow the word to appear as a literal in the BANNED list / docs.
+ if (/BANNED|banned/i.test(text.slice(Math.max(0, m.index - 60), m.index))) {
+ continue;
+ }
+ violations++;
+ const line = text.slice(0, m.index).split("\n").length;
+ console.log(` ✗ ${rel}:${line} banned word "${m[1]}"`);
+ }
+ console.log(` - ${rel}: scanned`);
+}
+
+// Rule #1 - every sourceUrl allowlisted (scan mock data + any string ending in a gov host)
+console.log("\n#1 government sources");
+const dataFile = path.join(ROOT, "lib/mock-data.ts");
+if (fs.existsSync(dataFile)) {
+ const text = fs.readFileSync(dataFile, "utf8");
+ const urlRe = /sourceUrl:\s*"([^"]+)"/g;
+ let m;
+ let count = 0;
+ while ((m = urlRe.exec(text))) {
+ checkAllowed(m[1], "lib/mock-data.ts");
+ count++;
+ }
+ console.log(` - checked ${count} sourceUrl values`);
+}
+
+console.log("\n----------------------");
+if (violations === 0) {
+ console.log("PASS - 0 neutrality violations");
+ process.exit(0);
+}
+console.log(`FAIL - ${violations} neutrality violation(s)`);
+process.exit(1);
next-env.d.ts +5 -0
@@ -0,0 +1,5 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.js +6 -0
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+};
+
+module.exports = nextConfig;
package.json +28 -0
@@ -0,0 +1,28 @@
+{
+ "name": "docket",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "neutrality": "node lint/neutrality.mjs"
+ },
+ "dependencies": {
+ "next": "14.2.15",
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "20.16.11",
+ "@types/react": "18.3.11",
+ "@types/react-dom": "18.3.0",
+ "autoprefixer": "10.4.20",
+ "eslint": "8.57.1",
+ "eslint-config-next": "14.2.15",
+ "postcss": "8.4.47",
+ "tailwindcss": "3.4.13",
+ "typescript": "5.6.3"
+ }
+}
postcss.config.js +6 -0
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
public/favicon.svg +6 -0
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
+ <rect width="32" height="32" rx="5" fill="#E0A53B"/>
+ <text x="16" y="17" text-anchor="middle" dominant-baseline="central"
+ font-family="Arial, Helvetica, sans-serif" font-weight="800"
+ font-size="20" fill="#0B0C0E">D</text>
+</svg>
services/ingest/INGEST_NOTES.md +147 -0
@@ -0,0 +1,147 @@
+# Docket Ingest Service - Notes
+
+## What Was Built
+
+Python ingest spine for the Docket government-activity tracker (Phase 1).
+All code lives at `services/ingest/` in the repo.
+
+### Module layout
+
+```
+ingest/
+├── __init__.py
+├── run.py # CLI entrypoint (python -m ingest.run --once)
+├── db.py # SQLite schema + connection (Postgres target in prod)
+├── change_detection.py # Dedupe on (source, externalId, updateDate)
+├── ranking.py # Feed ranking - only 4 permitted fields (rule #3)
+├── source_validator.py # .gov allowlist enforcement (rule #1)
+└── pollers/
+ ├── usaspending.py # FULLY LIVE - no auth, pulls real awards
+ ├── congress.py # Skeleton - needs DOCKET_CONGRESS_KEY
+ ├── govinfo.py # Skeleton - needs DOCKET_CONGRESS_KEY (same key)
+ ├── treasury.py # Skeleton - no auth (endpoints need verification)
+ ├── votes_house.py # Live - House Clerk XML, no auth
+ ├── votes_senate.py # Live - Senate roll-call XML, no auth
+ └── openstates.py # Skeleton - needs DOCKET_OPENSTATES_KEY (paid tier)
+tests/
+├── test_ranking.py # 16 tests - neutrality rule #3 enforcement
+└── test_source_url.py # 19 tests - neutrality rule #1 enforcement
+```
+
+## How to Run
+
+### Setup (one-time on CT241)
+
+```bash
+# Venv must be local - the CIFS share (/shared/projects) does not support
+# SQLite file locking or venv symlinks
+python3 -m venv /tmp/docket-venv
+/tmp/docket-venv/bin/pip install -r /shared/projects/docket/services/ingest/requirements.txt
+
+# SQLite DB also lives locally for the same reason
+mkdir -p /var/tmp/docket
+```
+
+### Run all pollers once
+
+```bash
+cd /shared/projects/docket/services/ingest
+/tmp/docket-venv/bin/python -m ingest.run --once
+```
+
+### Run one poller
+
+```bash
+/tmp/docket-venv/bin/python -m ingest.run --source usaspending
+/tmp/docket-venv/bin/python -m ingest.run --source votes_senate
+```
+
+### Run tests
+
+```bash
+cd /shared/projects/docket/services/ingest
+/tmp/docket-venv/bin/python -m pytest tests/ -v
+```
+
+## Test Results (verified 2026-06-26 on CT241)
+
+```
+35 passed in 4.09s
+```
+
+- `test_ranking.py` (16 tests): all ranking-function neutrality assertions pass,
+ including AST inspection that confirms no forbidden fields (social/engagement/news)
+ appear in any ranking helper.
+- `test_source_url.py` (19 tests): all source-URL allowlist assertions pass,
+ including integration tests writing to a temp SQLite DB.
+
+## Live Ingest Run Results (2026-06-26)
+
+```
+usaspending → 320 new award rows
+votes_senate → 17 new Senate vote rows
+congress → 0 (no API key)
+govinfo → 0 (no API key)
+treasury → 0 (endpoint paths need verification)
+votes_house → 0 (no votes found for 2026 - correct, House not in session)
+openstates → 0 (no API key)
+```
+
+### Sample award row (largest by amount)
+
+```
+source: usaspending
+external_id: CONT_AWD_DEAC3608GO28308_8900_-NONE-_-NONE-
+agency: Department of Energy
+recipient: ALLIANCE FOR ENERGY INNOVATION, LLC
+amount: $10,788,357,674.94
+action_date: (null - this award type reports null action_date from USAspending)
+program: other
+source_url: https://www.usaspending.gov/award/CONT_AWD_DEAC3608GO28308_8900_-NONE-_-NONE-
+```
+
+SQLite DB location on CT241: `/var/tmp/docket/docket.db`
+Override with: `export DOCKET_DB_PATH=/path/to/docket.db`
+
+## API Keys Required
+
+### Congress.gov + GovInfo (same key)
+- **Env var:** `DOCKET_CONGRESS_KEY`
+- **Get it free:** https://api.data.gov/signup
+- Rate limit: 5,000 req/hr (Congress.gov) / 36,000 req/hr (GovInfo)
+- Without this key the congress and govinfo pollers skip gracefully - no crash.
+
+### OpenStates (state bills)
+- **Env var:** `DOCKET_OPENSTATES_KEY`
+- **Free tier:** 500 req/day - too tight for production.
+- **Required tier:** Bronze (5k/day) or Silver (50k/day) - contact OpenStates for pricing.
+- Without this key the openstates poller skips gracefully - no crash.
+
+## Known Issues / Follow-ups
+
+1. **Treasury endpoints return 404** - the fiscaldata.treasury.gov API paths may
+ have changed. Needs a quick endpoint audit against their API docs before the
+ Treasury poller will be useful.
+
+2. **USAspending `loans` group returns 400** - the loan award codes (07, 08) seem
+ to require a different query structure. The other 4 groups work correctly.
+
+3. **`Action Date` is null for many USAspending awards** - the `/search/spending_by_award/`
+ endpoint returns null for Action Date on many records. For completeness, use the
+ `/bulk_download/` endpoint or per-award detail endpoint to get richer metadata.
+
+4. **SQLite on CIFS does not work** - `/shared/projects` on CT241 is a CIFS mount,
+ which does not support SQLite's file-locking mechanism. DB defaults to local
+ `/var/tmp/docket/docket.db`. For production, migrate to Postgres on a dedicated CT.
+
+5. **Venv on CIFS does not work** - same CIFS limitation; venv must live in
+ `/tmp/docket-venv` or another local path on CT241.
+
+## Neutrality Enforcement (architectural summary)
+
+- **Rule #1 (gov-source-only):** `source_validator.validate_source_url()` is called
+ before every DB write. Raises `SourceURLError` on any non-`.gov` host. Enforced
+ by 19 unit tests.
+- **Rule #3 (action-only ranking):** `ranking.score()` accepts exactly 4 parameters
+ (`actionDate`, `dollarAmount`, `affectedEstimate`, `actionType`). AST inspection
+ in 5 unit tests confirms no engagement/social/news field appears in any helper.
services/ingest/ingest/__init__.py +1 -0
@@ -0,0 +1 @@
+# Docket ingest package
services/ingest/ingest/change_detection.py +46 -0
@@ -0,0 +1,46 @@
+"""
+Change-detection helpers.
+Dedupe contract: a (source, externalId, updateDate) triple is processed
+exactly once. Idempotent across restarts.
+"""
+
+import sqlite3
+
+
+def is_new_event(
+ conn: sqlite3.Connection,
+ source: str,
+ external_id: str,
+ update_date: str,
+) -> bool:
+ """
+ Return True if this (source, external_id, update_date) has NOT been seen.
+ Empty update_date is treated as "always new" so we never silently swallow
+ records whose dates are missing.
+ """
+ if not update_date:
+ return True
+ row = conn.execute(
+ "SELECT update_date FROM seen WHERE source=? AND external_id=?",
+ (source, external_id),
+ ).fetchone()
+ if row is None:
+ return True
+ return row["update_date"] != update_date
+
+
+def mark_seen(
+ conn: sqlite3.Connection,
+ source: str,
+ external_id: str,
+ update_date: str,
+) -> None:
+ """Record that we have processed this version of the record."""
+ conn.execute(
+ """
+ INSERT INTO seen (source, external_id, update_date)
+ VALUES (?, ?, ?)
+ ON CONFLICT(source, external_id) DO UPDATE SET update_date=excluded.update_date
+ """,
+ (source, external_id, update_date),
+ )
services/ingest/ingest/db.py +122 -0
@@ -0,0 +1,122 @@
+"""
+SQLite schema and connection helpers for the Docket ingest service.
+Phase 0/1 spike - Postgres is the production target (see ARCHITECTURE.md).
+"""
+
+import os
+import sqlite3
+
+# Default to a local path - SQLite does not work on CIFS/NFS mounts.
+# Override with DOCKET_DB_PATH env var, or set to a local dir (e.g. /var/tmp/docket).
+_DEFAULT_LOCAL = os.path.join("/var", "tmp", "docket", "docket.db")
+_SHARE_RELATIVE = os.path.join(os.path.dirname(__file__), "..", "data", "docket.db")
+# Use env override if set; else use the local path.
+DB_PATH = os.environ.get("DOCKET_DB_PATH", _DEFAULT_LOCAL)
+
+
+def get_connection(path: str = DB_PATH) -> sqlite3.Connection:
+ """Return a sqlite3 connection with row_factory set."""
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ conn = sqlite3.connect(path)
+ conn.row_factory = sqlite3.Row
+ # WAL mode is not supported on CIFS/NFS mounts; use default DELETE journal.
+ conn.execute("PRAGMA foreign_keys=ON")
+ conn.execute("PRAGMA busy_timeout=10000")
+ return conn
+
+
+def init_schema(conn: sqlite3.Connection) -> None:
+ """Create all tables if they do not exist."""
+ conn.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS bills (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL,
+ external_id TEXT NOT NULL,
+ congress INTEGER,
+ session INTEGER,
+ number TEXT,
+ title TEXT,
+ sponsor_id TEXT,
+ status TEXT,
+ policy_area TEXT,
+ updated_at TEXT,
+ source_url TEXT NOT NULL,
+ UNIQUE (source, external_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS actions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ bill_id INTEGER REFERENCES bills(id),
+ source TEXT NOT NULL,
+ action_type TEXT NOT NULL,
+ action_date TEXT NOT NULL,
+ chamber TEXT,
+ text TEXT,
+ dollar_amount REAL,
+ affected_estimate INTEGER,
+ source_url TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS awards (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL DEFAULT 'usaspending',
+ external_id TEXT NOT NULL,
+ agency TEXT,
+ recipient TEXT,
+ amount REAL,
+ action_date TEXT,
+ program TEXT,
+ source_url TEXT NOT NULL,
+ UNIQUE (source, external_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS votes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ bill_id INTEGER REFERENCES bills(id),
+ chamber TEXT NOT NULL,
+ date TEXT NOT NULL,
+ yea INTEGER,
+ nay INTEGER,
+ present INTEGER,
+ result TEXT,
+ source_url TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS fiscal (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ metric TEXT NOT NULL,
+ date TEXT NOT NULL,
+ value REAL,
+ source_url TEXT NOT NULL,
+ UNIQUE (metric, date)
+ );
+
+ CREATE TABLE IF NOT EXISTS events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ref_type TEXT NOT NULL,
+ ref_id INTEGER NOT NULL,
+ action_date TEXT NOT NULL,
+ dollar_amount REAL,
+ affected_estimate INTEGER,
+ action_type TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS raw_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL,
+ external_id TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ fetched_at TEXT NOT NULL,
+ UNIQUE (source, external_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS seen (
+ source TEXT NOT NULL,
+ external_id TEXT NOT NULL,
+ update_date TEXT NOT NULL,
+ PRIMARY KEY (source, external_id)
+ );
+ """
+ )
+ conn.commit()
services/ingest/ingest/pollers/__init__.py +1 -0
@@ -0,0 +1 @@
+# Docket pollers package
services/ingest/ingest/pollers/congress.py +204 -0
@@ -0,0 +1,204 @@
+"""
+Congress.gov API poller - federal bills and actions.
+
+Endpoint: GET https://api.congress.gov/v3/bill
+Auth: free api.data.gov key in env DOCKET_CONGRESS_KEY
+ If not set, logs a clear message and returns 0 (no crash).
+Change-detection: (source='congress', externalId, updateDate)
+Source URL: https://www.congress.gov/bill/{congress}th-congress/{type}/{number}
+
+Rate limit: 5,000 req/hr at 250 records/call → poll every 15-30 min in prod.
+Reliability: Congress.gov had a silent multi-day outage Aug 2025; ingest
+ tolerates absence gracefully (skip, don't crash the run).
+"""
+
+import json
+import logging
+import os
+import sqlite3
+from datetime import datetime, timedelta
+from typing import Optional
+
+import requests
+
+from ingest.change_detection import is_new_event, mark_seen
+from ingest.db import get_connection, init_schema
+from ingest.source_validator import validate_source_url
+
+logger = logging.getLogger(__name__)
+
+SOURCE = "congress"
+BASE_URL = "https://api.congress.gov/v3"
+BILL_WEB_BASE = "https://www.congress.gov/bill"
+
+CONGRESS_KEY_ENV = "DOCKET_CONGRESS_KEY"
+
+
+def _get_api_key() -> Optional[str]:
+ return os.environ.get(CONGRESS_KEY_ENV)
+
+
+def _bill_source_url(congress: int, bill_type: str, number: str) -> str:
+ """Construct the canonical Congress.gov bill URL."""
+ # E.g. https://www.congress.gov/bill/119th-congress/house-bill/1234
+ type_map = {
+ "hr": "house-bill",
+ "s": "senate-bill",
+ "hjres": "house-joint-resolution",
+ "sjres": "senate-joint-resolution",
+ "hconres": "house-concurrent-resolution",
+ "sconres": "senate-concurrent-resolution",
+ "hres": "house-resolution",
+ "sres": "senate-resolution",
+ }
+ type_slug = type_map.get(bill_type.lower(), bill_type.lower())
+ return f"{BILL_WEB_BASE}/{congress}th-congress/{type_slug}/{number}"
+
+
+def poll(days_back: int = 1, db_path: Optional[str] = None) -> int:
+ """
+ Fetch recently updated bills from Congress.gov.
+
+ If DOCKET_CONGRESS_KEY is not set, logs a clear instruction and returns 0.
+
+ Parameters
+ ----------
+ days_back : How far back to query (default 1 day; prod uses 15-30 min intervals).
+ db_path : Override the default SQLite path (useful in tests).
+
+ Returns
+ -------
+ Number of new bill-action rows stored.
+ """
+ api_key = _get_api_key()
+ if not api_key:
+ logger.warning(
+ "Congress.gov poller: DOCKET_CONGRESS_KEY is not set. "
+ "Obtain a free key at https://api.data.gov/signup and export it as "
+ "DOCKET_CONGRESS_KEY=<your_key> before running. Skipping this source."
+ )
+ return 0
+
+ from_dt = (datetime.utcnow() - timedelta(days=days_back)).strftime(
+ "%Y-%m-%dT%H:%M:%SZ"
+ )
+
+ params = {
+ "api_key": api_key,
+ "sort": "updateDate",
+ "fromDateTime": from_dt,
+ "limit": 250,
+ }
+
+ url = f"{BASE_URL}/bill"
+ try:
+ resp = requests.get(url, params=params, timeout=30)
+ resp.raise_for_status()
+ except requests.RequestException as exc:
+ logger.error("Congress.gov request failed: %s - skipping this poll cycle.", exc)
+ return 0
+
+ data = resp.json()
+ bills = data.get("bills", [])
+
+ if not bills:
+ logger.info("Congress.gov: no updated bills since %s", from_dt)
+ return 0
+
+ logger.info("Congress.gov: received %d bill(s)", len(bills))
+
+ conn = get_connection(db_path) if db_path else get_connection()
+ init_schema(conn)
+
+ stored = 0
+ for bill in bills:
+ bill_type = bill.get("type", "").lower()
+ number = str(bill.get("number", ""))
+ congress = bill.get("congress", 0)
+ external_id = f"{congress}-{bill_type}-{number}"
+ update_date = bill.get("updateDate", "")
+ title = bill.get("title", "")
+
+ if not is_new_event(conn, SOURCE, external_id, update_date):
+ continue
+
+ source_url = _bill_source_url(congress, bill_type, number)
+ try:
+ validate_source_url(source_url)
+ except Exception as exc:
+ logger.error("Congress: invalid source_url %s: %s", source_url, exc)
+ continue
+
+ # Upsert bill
+ conn.execute(
+ """
+ INSERT INTO bills
+ (source, external_id, congress, number, title,
+ status, updated_at, source_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(source, external_id) DO UPDATE SET
+ title = excluded.title,
+ status = excluded.status,
+ updated_at = excluded.updated_at,
+ source_url = excluded.source_url
+ """,
+ (
+ SOURCE,
+ external_id,
+ congress,
+ number,
+ title,
+ bill.get("latestAction", {}).get("text", ""),
+ update_date,
+ source_url,
+ ),
+ )
+
+ bill_row = conn.execute(
+ "SELECT id FROM bills WHERE source=? AND external_id=?",
+ (SOURCE, external_id),
+ ).fetchone()
+ bill_id = bill_row["id"] if bill_row else None
+
+ # Store latest action
+ latest_action = bill.get("latestAction", {})
+ action_date = latest_action.get("actionDate", update_date[:10] if update_date else "")
+ action_text = latest_action.get("text", "")
+
+ if bill_id and action_date:
+ conn.execute(
+ """
+ INSERT INTO actions
+ (bill_id, source, action_type, action_date, chamber, text, source_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ bill_id,
+ SOURCE,
+ "legislative_action",
+ action_date,
+ bill.get("originChamber", ""),
+ action_text,
+ source_url,
+ ),
+ )
+
+ # Raw event
+ conn.execute(
+ """
+ INSERT INTO raw_events (source, external_id, payload, fetched_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(source, external_id) DO UPDATE SET
+ payload = excluded.payload,
+ fetched_at = excluded.fetched_at
+ """,
+ (SOURCE, external_id, json.dumps(bill), datetime.utcnow().isoformat()),
+ )
+
+ mark_seen(conn, SOURCE, external_id, update_date)
+ stored += 1
+
+ conn.commit()
+ conn.close()
+ logger.info("Congress.gov: stored %d new bill record(s)", stored)
+ return stored
services/ingest/ingest/pollers/govinfo.py +105 -0
@@ -0,0 +1,105 @@
+"""
+GovInfo API poller - full bill text, Congressional Record, public laws.
+
+Endpoint: GET https://api.govinfo.gov/published/{start}/{end}
+Auth: same api.data.gov key as Congress.gov (DOCKET_CONGRESS_KEY)
+Rate: 36,000 req/hr - most generous of the four primary sources.
+Lag: 1-2 business days behind the legislative action.
+
+If DOCKET_CONGRESS_KEY is not set, this poller skips gracefully.
+"""
+
+import json
+import logging
+import os
+import sqlite3
+from datetime import datetime, timedelta
+from typing import Optional
+
+import requests
+
+from ingest.change_detection import is_new_event, mark_seen
+from ingest.db import get_connection, init_schema
+from ingest.source_validator import validate_source_url
+
+logger = logging.getLogger(__name__)
+
+SOURCE = "govinfo"
+BASE_URL = "https://api.govinfo.gov"
+
+COLLECTIONS = ["BILLS", "PLAW", "CREC"]
+
+
+def poll(days_back: int = 2, db_path: Optional[str] = None) -> int:
+ """
+ Fetch recently published GovInfo packages (bill text, public laws, CR).
+
+ If DOCKET_CONGRESS_KEY is not set, skips gracefully and returns 0.
+ """
+ api_key = os.environ.get("DOCKET_CONGRESS_KEY")
+ if not api_key:
+ logger.warning(
+ "GovInfo poller: DOCKET_CONGRESS_KEY is not set. "
+ "Skipping GovInfo source (same key as Congress.gov - see congress.py)."
+ )
+ return 0
+
+ end_dt = datetime.utcnow()
+ start_dt = end_dt - timedelta(days=days_back)
+ start_str = start_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
+ end_str = end_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ conn = get_connection(db_path) if db_path else get_connection()
+ init_schema(conn)
+ stored = 0
+
+ for collection in COLLECTIONS:
+ url = f"{BASE_URL}/published/{start_str}/{end_str}"
+ params = {
+ "api_key": api_key,
+ "collection": collection,
+ "pageSize": 100,
+ }
+ try:
+ resp = requests.get(url, params=params, timeout=30)
+ resp.raise_for_status()
+ except requests.RequestException as exc:
+ logger.error("GovInfo %s request failed: %s", collection, exc)
+ continue
+
+ data = resp.json()
+ packages = data.get("packages", [])
+ logger.info("GovInfo %s: received %d package(s)", collection, len(packages))
+
+ for pkg in packages:
+ pkg_id = pkg.get("packageId", "")
+ update_date = pkg.get("dateIssued", pkg.get("lastModified", ""))
+
+ if not pkg_id or not is_new_event(conn, SOURCE, pkg_id, update_date):
+ continue
+
+ source_url = pkg.get("packageLink", f"https://www.govinfo.gov/content/pkg/{pkg_id}/pdf/{pkg_id}.pdf")
+ try:
+ validate_source_url(source_url)
+ except Exception as exc:
+ logger.error("GovInfo: invalid source_url %s: %s", source_url, exc)
+ continue
+
+ conn.execute(
+ """
+ INSERT INTO raw_events (source, external_id, payload, fetched_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(source, external_id) DO UPDATE SET
+ payload = excluded.payload,
+ fetched_at = excluded.fetched_at
+ """,
+ (SOURCE, pkg_id, json.dumps(pkg), datetime.utcnow().isoformat()),
+ )
+
+ mark_seen(conn, SOURCE, pkg_id, update_date)
+ stored += 1
+
+ conn.commit()
+ conn.close()
+ logger.info("GovInfo: stored %d new package(s)", stored)
+ return stored
services/ingest/ingest/pollers/openstates.py +161 -0
@@ -0,0 +1,161 @@
+"""
+OpenStates API v3 poller - 50-state + DC/PR legislative data.
+
+Base: https://v3.openstates.org/
+Auth: API key required (set DOCKET_OPENSTATES_KEY).
+Tiers: Free 500/day (too tight for prod) → paid Bronze/Silver.
+ Skips gracefully if key is not set.
+
+Per PLAN.md: state ingest is in MVP scope but production needs the paid tier.
+An honest per-state coverage map must be maintained so the UI never implies
+completeness it doesn't have.
+
+Source URLs: bill detail URLs point to the state's official legislature (.gov).
+ If the OpenStates record lacks an official URL, the bill is skipped
+ (neutrality rule #1: no fact without an allowlisted .gov source_url).
+"""
+
+import json
+import logging
+import os
+from datetime import datetime, timedelta
+from typing import Optional
+
+import requests
+
+from ingest.change_detection import is_new_event, mark_seen
+from ingest.db import get_connection, init_schema
+from ingest.source_validator import validate_source_url
+
+logger = logging.getLogger(__name__)
+
+SOURCE = "openstates"
+BASE_URL = "https://v3.openstates.org"
+KEY_ENV = "DOCKET_OPENSTATES_KEY"
+
+# States to poll in the initial MVP run (all 50 + DC can be added incrementally).
+MVP_JURISDICTIONS = [
+ "us", # federal (fallback - OpenStates also covers Congress)
+ "ca", "tx", "fl", "ny", "il", "pa", "oh", "ga", "nc", "mi",
+]
+
+
+def _get_api_key() -> Optional[str]:
+ return os.environ.get(KEY_ENV)
+
+
+def poll(days_back: int = 1, db_path: Optional[str] = None) -> int:
+ """
+ Fetch recently updated bills from OpenStates for MVP_JURISDICTIONS.
+
+ If DOCKET_OPENSTATES_KEY is not set, logs a clear message and returns 0.
+ NOTE: Free tier (500 req/day) is too tight for prod - a paid plan is required.
+
+ Returns
+ -------
+ Number of new bill rows stored.
+ """
+ api_key = _get_api_key()
+ if not api_key:
+ logger.warning(
+ "OpenStates poller: DOCKET_OPENSTATES_KEY is not set. "
+ "Free tier: 500 req/day (insufficient for prod). "
+ "Contact OpenStates for Bronze/Silver paid tier pricing. "
+ "Skipping state ingest this run."
+ )
+ return 0
+
+ conn = get_connection(db_path) if db_path else get_connection()
+ init_schema(conn)
+ stored = 0
+
+ since = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%d")
+ headers = {"X-API-KEY": api_key}
+
+ for jid in MVP_JURISDICTIONS:
+ url = f"{BASE_URL}/bills"
+ params = {
+ "jurisdiction": jid,
+ "updated_since": since,
+ "sort": "-updated_at",
+ "per_page": 20,
+ "page": 1,
+ }
+ try:
+ resp = requests.get(url, params=params, headers=headers, timeout=30)
+ resp.raise_for_status()
+ except requests.RequestException as exc:
+ logger.error("OpenStates %s request failed: %s", jid, exc)
+ continue
+
+ bills = resp.json().get("results", [])
+ logger.info("OpenStates %s: received %d bill(s)", jid, len(bills))
+
+ for bill in bills:
+ bill_id = bill.get("id", "")
+ update_date = bill.get("updated_at", "")[:10] if bill.get("updated_at") else ""
+
+ if not bill_id or not is_new_event(conn, SOURCE, bill_id, update_date):
+ continue
+
+ # Require an official .gov source URL (neutrality rule #1).
+ sources = bill.get("sources", [])
+ source_url = ""
+ for s in sources:
+ url_candidate = s.get("url", "")
+ if validate_source_url.__module__ and url_candidate:
+ try:
+ validate_source_url(url_candidate)
+ source_url = url_candidate
+ break
+ except Exception:
+ continue
+
+ if not source_url:
+ logger.debug(
+ "OpenStates %s bill %s: no allowlisted .gov source_url - skipping (rule #1)",
+ jid,
+ bill_id,
+ )
+ continue
+
+ conn.execute(
+ """
+ INSERT INTO bills
+ (source, external_id, number, title, status, updated_at, source_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(source, external_id) DO UPDATE SET
+ title = excluded.title,
+ status = excluded.status,
+ updated_at = excluded.updated_at,
+ source_url = excluded.source_url
+ """,
+ (
+ SOURCE,
+ bill_id,
+ bill.get("identifier", ""),
+ bill.get("title", ""),
+ bill.get("status", ""),
+ update_date,
+ source_url,
+ ),
+ )
+
+ conn.execute(
+ """
+ INSERT INTO raw_events (source, external_id, payload, fetched_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(source, external_id) DO UPDATE SET
+ payload = excluded.payload,
+ fetched_at = excluded.fetched_at
+ """,
+ (SOURCE, bill_id, json.dumps(bill), datetime.utcnow().isoformat()),
+ )
+
+ mark_seen(conn, SOURCE, bill_id, update_date)
+ stored += 1
+
+ conn.commit()
+ conn.close()
+ logger.info("OpenStates: stored %d new bill(s)", stored)
+ return stored
services/ingest/ingest/pollers/treasury.py +136 -0
@@ -0,0 +1,136 @@
+"""
+Treasury Fiscal Data API poller - debt, deficit, daily cash flows.
+
+Endpoints used:
+ Debt to the Penny : /v1/accounting/od/debt_to_penny
+ Daily Treasury Stmt: /v1/accounting/dts/dts_table_1
+Auth: none
+
+Poll daily after ~6pm ET (data posted T+1).
+Source URLs: https://fiscaldata.treasury.gov/datasets/...
+"""
+
+import json
+import logging
+import sqlite3
+from datetime import datetime, timedelta
+from typing import Optional
+
+import requests
+
+from ingest.change_detection import is_new_event, mark_seen
+from ingest.db import get_connection, init_schema
+from ingest.source_validator import validate_source_url
+
+logger = logging.getLogger(__name__)
+
+SOURCE = "treasury"
+BASE_URL = "https://api.fiscaldata.treasury.gov/services/api/fiscal_service"
+
+DATASETS = [
+ {
+ "name": "debt_to_penny",
+ "endpoint": "/v1/accounting/od/debt_to_penny",
+ "metric_field": "tot_pub_debt_out_amt",
+ "date_field": "record_date",
+ "source_url": "https://fiscaldata.treasury.gov/datasets/debt-to-the-penny/debt-to-the-penny",
+ "metric_label": "total_public_debt",
+ },
+ {
+ "name": "dts_table_1",
+ "endpoint": "/v1/accounting/dts/dts_table_1",
+ "metric_field": "close_today_bal",
+ "date_field": "record_date",
+ "source_url": "https://fiscaldata.treasury.gov/datasets/daily-treasury-statement/operating-cash-balance",
+ "metric_label": "operating_cash_balance",
+ },
+]
+
+
+def _fetch_dataset(ds: dict, days_back: int = 2) -> list:
+ """Fetch recent rows for one Treasury dataset."""
+ start_date = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%d")
+ url = f"{BASE_URL}{ds['endpoint']}"
+ params = {
+ "fields": f"{ds['date_field']},{ds['metric_field']}",
+ "filter": f"{ds['date_field']}:gte:{start_date}",
+ "sort": f"-{ds['date_field']}",
+ "page[size]": 10,
+ "page[number]": 1,
+ }
+ try:
+ resp = requests.get(url, params=params, timeout=30)
+ resp.raise_for_status()
+ return resp.json().get("data", [])
+ except requests.RequestException as exc:
+ logger.error("Treasury %s request failed: %s", ds["name"], exc)
+ return []
+
+
+def poll(days_back: int = 2, db_path: Optional[str] = None) -> int:
+ """
+ Fetch recent Treasury fiscal data and store in the fiscal table.
+
+ Returns
+ -------
+ Number of new fiscal rows stored.
+ """
+ conn = get_connection(db_path) if db_path else get_connection()
+ init_schema(conn)
+ stored = 0
+
+ for ds in DATASETS:
+ rows = _fetch_dataset(ds, days_back)
+ logger.info("Treasury %s: received %d row(s)", ds["name"], len(rows))
+
+ source_url = ds["source_url"]
+ try:
+ validate_source_url(source_url)
+ except Exception as exc:
+ logger.error("Treasury: invalid source_url %s: %s", source_url, exc)
+ continue
+
+ for row in rows:
+ date_val = row.get(ds["date_field"], "")
+ value_str = row.get(ds["metric_field"], "")
+ if not date_val:
+ continue
+
+ external_id = f"{ds['metric_label']}:{date_val}"
+ if not is_new_event(conn, SOURCE, external_id, date_val):
+ continue
+
+ try:
+ value = float(value_str.replace(",", "")) if value_str else None
+ except ValueError:
+ value = None
+
+ conn.execute(
+ """
+ INSERT INTO fiscal (metric, date, value, source_url)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(metric, date) DO UPDATE SET
+ value = excluded.value,
+ source_url = excluded.source_url
+ """,
+ (ds["metric_label"], date_val, value, source_url),
+ )
+
+ conn.execute(
+ """
+ INSERT INTO raw_events (source, external_id, payload, fetched_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(source, external_id) DO UPDATE SET
+ payload = excluded.payload,
+ fetched_at = excluded.fetched_at
+ """,
+ (SOURCE, external_id, json.dumps(row), datetime.utcnow().isoformat()),
+ )
+
+ mark_seen(conn, SOURCE, external_id, date_val)
+ stored += 1
+
+ conn.commit()
+ conn.close()
+ logger.info("Treasury: stored %d new fiscal row(s)", stored)
+ return stored
services/ingest/ingest/pollers/usaspending.py +237 -0
@@ -0,0 +1,237 @@
+"""
+USAspending.gov poller - federal awards (contracts, grants, loans, direct).
+
+Endpoint: POST https://api.usaspending.gov/api/v2/search/spending_by_award/
+Auth: none
+Change-detection: (source, external_id, action_date)
+Source URL: https://www.usaspending.gov/award/{generated_internal_id}
+
+API constraint: award_type_codes must belong to exactly one group per request.
+Groups: contracts [A,B,C,D], grants [02-05], loans [07,08], direct [06],
+ idvs [IDV_A, IDV_B, IDV_B_A, IDV_B_B, IDV_B_C], other [10, 06].
+We issue one request per group and merge the results.
+"""
+
+import json
+import logging
+import sqlite3
+from datetime import datetime, timedelta
+from typing import Optional
+
+import requests
+
+from ingest.change_detection import is_new_event, mark_seen
+from ingest.db import get_connection, init_schema
+from ingest.source_validator import validate_source_url
+
+logger = logging.getLogger(__name__)
+
+SOURCE = "usaspending"
+BASE_URL = "https://api.usaspending.gov/api/v2"
+AWARD_DETAIL_BASE = "https://www.usaspending.gov/award"
+
+# Each group must be queried in a separate API request.
+AWARD_TYPE_GROUPS = {
+ "contracts": ["A", "B", "C", "D"],
+ "grants": ["02", "03", "04", "05"],
+ "loans": ["07", "08"],
+ "direct": ["06", "10"],
+ "idvs": ["IDV_A", "IDV_B", "IDV_B_A", "IDV_B_B", "IDV_B_C"],
+}
+
+AWARD_TYPE_LABEL = {
+ "A": "contract", "B": "contract", "C": "contract", "D": "contract",
+ "02": "grant", "03": "grant", "04": "grant", "05": "grant",
+ "07": "loan", "08": "loan",
+ "06": "direct", "10": "direct",
+ "IDV_A": "idv", "IDV_B": "idv", "IDV_B_A": "idv", "IDV_B_B": "idv", "IDV_B_C": "idv",
+}
+
+REQUESTED_FIELDS = [
+ "Award ID",
+ "Recipient Name",
+ "Award Amount",
+ "Award Type",
+ "Awarding Agency",
+ "Action Date",
+ "Description",
+ "generated_internal_id",
+]
+
+
+def _build_payload(
+ start_date: str,
+ end_date: str,
+ type_codes: list,
+ page: int = 1,
+) -> dict:
+ return {
+ "filters": {
+ "time_period": [{"start_date": start_date, "end_date": end_date}],
+ "award_type_codes": type_codes,
+ },
+ "fields": REQUESTED_FIELDS,
+ "sort": "Award Amount",
+ "order": "desc",
+ "limit": 100,
+ "page": page,
+ }
+
+
+def _store_award(conn: sqlite3.Connection, award: dict, source_url: str) -> Optional[int]:
+ """Insert or replace an award row; return the row id."""
+ conn.execute(
+ """
+ INSERT INTO awards (source, external_id, agency, recipient, amount,
+ action_date, program, source_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(source, external_id)
+ DO UPDATE SET
+ agency = excluded.agency,
+ recipient = excluded.recipient,
+ amount = excluded.amount,
+ action_date = excluded.action_date,
+ program = excluded.program,
+ source_url = excluded.source_url
+ """,
+ (
+ SOURCE,
+ award["external_id"],
+ award.get("Awarding Agency") or "",
+ award.get("Recipient Name") or "",
+ award.get("Award Amount") or 0.0,
+ award.get("Action Date") or "",
+ AWARD_TYPE_LABEL.get(award.get("Award Type") or "", "other"),
+ source_url,
+ ),
+ )
+ row = conn.execute(
+ "SELECT id FROM awards WHERE source=? AND external_id=?",
+ (SOURCE, award["external_id"]),
+ ).fetchone()
+ return row["id"] if row else None
+
+
+def _fetch_group(
+ group_name: str,
+ type_codes: list,
+ start_str: str,
+ end_str: str,
+) -> list:
+ """Fetch up to 100 awards for one award-type group."""
+ url = f"{BASE_URL}/search/spending_by_award/"
+ payload = _build_payload(start_str, end_str, type_codes, page=1)
+ try:
+ resp = requests.post(url, json=payload, timeout=60)
+ resp.raise_for_status()
+ except requests.RequestException as exc:
+ logger.error("USAspending %s request failed: %s", group_name, exc)
+ return []
+ data = resp.json()
+ results = data.get("results", [])
+ logger.info("USAspending %s: %d award(s)", group_name, len(results))
+ return results
+
+
+def poll(days_back: int = 7, db_path: Optional[str] = None) -> int:
+ """
+ Pull recent awards from USAspending.gov (one request per award-type group).
+
+ Parameters
+ ----------
+ days_back : Number of days of history to fetch (default 7).
+ db_path : Override the default SQLite path (useful in tests).
+
+ Returns
+ -------
+ Number of new award rows stored.
+ """
+ end_date = datetime.utcnow().date()
+ start_date = end_date - timedelta(days=days_back)
+ start_str = start_date.isoformat()
+ end_str = end_date.isoformat()
+
+ logger.info("USAspending: fetching awards %s → %s", start_str, end_str)
+
+ conn = get_connection(db_path) if db_path else get_connection()
+ init_schema(conn)
+
+ stored = 0
+
+ for group_name, type_codes in AWARD_TYPE_GROUPS.items():
+ results = _fetch_group(group_name, type_codes, start_str, end_str)
+
+ for award in results:
+ internal_id = (
+ award.get("generated_internal_id")
+ or award.get("Award ID")
+ or ""
+ )
+ if not internal_id:
+ logger.debug("USAspending: skipping award with no ID")
+ continue
+
+ award["external_id"] = internal_id
+ action_date = award.get("Action Date") or ""
+
+ if not is_new_event(conn, SOURCE, internal_id, action_date):
+ logger.debug(
+ "USAspending: already seen %s @ %s", internal_id, action_date
+ )
+ continue
+
+ source_url = f"{AWARD_DETAIL_BASE}/{internal_id}"
+ try:
+ validate_source_url(source_url)
+ except Exception as exc:
+ logger.error(
+ "USAspending: invalid source_url %s: %s", source_url, exc
+ )
+ continue
+
+ # Raw event
+ conn.execute(
+ """
+ INSERT INTO raw_events (source, external_id, payload, fetched_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(source, external_id) DO UPDATE SET
+ payload = excluded.payload,
+ fetched_at = excluded.fetched_at
+ """,
+ (
+ SOURCE,
+ internal_id,
+ json.dumps(award),
+ datetime.utcnow().isoformat(),
+ ),
+ )
+
+ # Normalized award
+ award_id = _store_award(conn, award, source_url)
+
+ # Feed event - only the 4 permitted ranking fields
+ if award_id is not None:
+ conn.execute(
+ """
+ INSERT INTO events
+ (ref_type, ref_id, action_date, dollar_amount,
+ affected_estimate, action_type)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ "award",
+ award_id,
+ action_date,
+ award.get("Award Amount") or 0.0,
+ None,
+ AWARD_TYPE_LABEL.get(award.get("Award Type") or "", "other"),
+ ),
+ )
+
+ mark_seen(conn, SOURCE, internal_id, action_date)
+ stored += 1
+
+ conn.commit()
+ conn.close()
+ logger.info("USAspending: stored %d new award(s) total", stored)
+ return stored
services/ingest/ingest/pollers/votes_house.py +167 -0
@@ -0,0 +1,167 @@
+"""
+House Clerk XML roll-call vote poller.
+
+Source: https://clerk.house.gov/evs/{year}/ROLL_{vote_num:03d}.xml
+No auth required. Posted within hours of the vote.
+
+Strategy: discover the latest vote number by checking sequentially from the
+last known; stop when a 404 is returned. Stores votes and emits feed events.
+"""
+
+import logging
+import sqlite3
+import xml.etree.ElementTree as ET
+from datetime import datetime
+from typing import Optional
+
+import requests
+
+from ingest.change_detection import is_new_event, mark_seen
+from ingest.db import get_connection, init_schema
+from ingest.source_validator import validate_source_url
+
+logger = logging.getLogger(__name__)
+
+SOURCE = "votes_house"
+BASE_URL = "https://clerk.house.gov/evs"
+
+
+def _roll_url(year: int, roll_num: int) -> str:
+ return f"{BASE_URL}/{year}/ROLL_{roll_num:03d}.xml"
+
+
+def _parse_vote_xml(xml_text: str, source_url: str) -> Optional[dict]:
+ """Parse House roll-call XML; return a dict or None on parse error."""
+ try:
+ root = ET.fromstring(xml_text)
+ except ET.ParseError as exc:
+ logger.error("votes_house: XML parse error: %s", exc)
+ return None
+
+ vote_metadata = root.find("vote-metadata")
+ if vote_metadata is None:
+ return None
+
+ def text(tag: str) -> str:
+ el = vote_metadata.find(tag)
+ return el.text.strip() if el is not None and el.text else ""
+
+ action_date = text("action-date") # MM/DD/YYYY
+ # Normalise to ISO
+ try:
+ action_date_iso = datetime.strptime(action_date, "%m/%d/%Y").strftime("%Y-%m-%d")
+ except ValueError:
+ action_date_iso = action_date
+
+ yea_count = nay_count = present_count = 0
+ totals = vote_metadata.find("vote-totals")
+ if totals is not None:
+ for totals_by_party in totals.findall("totals-by-vote"):
+ yea_count = int(totals_by_party.findtext("yea-total", "0") or 0)
+ nay_count = int(totals_by_party.findtext("nay-total", "0") or 0)
+ present_count = int(totals_by_party.findtext("present-total", "0") or 0)
+
+ result_el = vote_metadata.find("vote-result")
+ result = result_el.text.strip() if result_el is not None and result_el.text else ""
+
+ return {
+ "action_date": action_date_iso,
+ "yea": yea_count,
+ "nay": nay_count,
+ "present": present_count,
+ "result": result,
+ "source_url": source_url,
+ }
+
+
+def poll(year: Optional[int] = None, max_new: int = 20, db_path: Optional[str] = None) -> int:
+ """
+ Fetch new House roll-call votes for the given year (default: current year).
+
+ Probes vote numbers sequentially from the last seen + 1 until a 404.
+ Limits to max_new new votes per call to avoid runaway HTTP traffic.
+
+ Returns
+ -------
+ Number of new vote rows stored.
+ """
+ if year is None:
+ year = datetime.utcnow().year
+
+ conn = get_connection(db_path) if db_path else get_connection()
+ init_schema(conn)
+
+ # Find the highest vote number we've already seen for this year.
+ last_row = conn.execute(
+ "SELECT external_id FROM seen WHERE source=? AND external_id LIKE ? ORDER BY external_id DESC LIMIT 1",
+ (SOURCE, f"{year}-%"),
+ ).fetchone()
+
+ if last_row:
+ last_num = int(last_row["external_id"].split("-")[1])
+ else:
+ last_num = 0
+
+ stored = 0
+ probe_num = last_num + 1
+
+ while stored < max_new:
+ external_id = f"{year}-{probe_num:03d}"
+ if not is_new_event(conn, SOURCE, external_id, external_id):
+ probe_num += 1
+ continue
+
+ url = _roll_url(year, probe_num)
+ try:
+ resp = requests.get(url, timeout=15)
+ except requests.RequestException as exc:
+ logger.error("votes_house: request error %s: %s", url, exc)
+ break
+
+ if resp.status_code == 404:
+ logger.info("votes_house: no vote %s (probe stopped)", external_id)
+ break
+ if not resp.ok:
+ logger.warning("votes_house: HTTP %d for %s", resp.status_code, url)
+ break
+
+ try:
+ validate_source_url(url)
+ except Exception as exc:
+ logger.error("votes_house: invalid source_url %s: %s", url, exc)
+ break
+
+ vote = _parse_vote_xml(resp.text, url)
+ if vote is None:
+ probe_num += 1
+ continue
+
+ conn.execute(
+ """
+ INSERT INTO votes (chamber, date, yea, nay, present, result, source_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ ("house", vote["action_date"], vote["yea"], vote["nay"],
+ vote["present"], vote["result"], vote["source_url"]),
+ )
+
+ vote_row = conn.execute("SELECT last_insert_rowid() AS id").fetchone()
+ if vote_row:
+ conn.execute(
+ """
+ INSERT INTO events
+ (ref_type, ref_id, action_date, dollar_amount,
+ affected_estimate, action_type)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ ("vote", vote_row["id"], vote["action_date"], None, None, "floor_vote"),
+ )
+
+ mark_seen(conn, SOURCE, external_id, external_id)
+ stored += 1
+ probe_num += 1
+
+ conn.commit()
+ conn.close()
+ logger.info("votes_house: stored %d new vote(s) (year %d)", stored, year)
+ return stored
services/ingest/ingest/pollers/votes_senate.py +168 -0
@@ -0,0 +1,168 @@
+"""
+Senate roll-call XML poller.
+
+Source: https://www.senate.gov/legislative/LIS/roll_call_lists/roll_call_vote_cfm.cfm
+ and individual vote XML:
+ https://www.senate.gov/legislative/LIS/roll_call_votes/vote{congress}{session}/vote_{congress}_{session}_{vote_num:05d}.xml
+
+No auth required. Posted within hours of the vote.
+
+Strategy: probe vote numbers sequentially from last known; stop on 404.
+"""
+
+import logging
+import xml.etree.ElementTree as ET
+from datetime import datetime
+from typing import Optional
+
+import requests
+
+from ingest.change_detection import is_new_event, mark_seen
+from ingest.db import get_connection, init_schema
+from ingest.source_validator import validate_source_url
+
+logger = logging.getLogger(__name__)
+
+SOURCE = "votes_senate"
+VOTE_XML_BASE = "https://www.senate.gov/legislative/LIS/roll_call_votes"
+
+# 119th Congress started Jan 2025; sessions typically 1 = odd year, 2 = even year.
+CURRENT_CONGRESS = 119
+CURRENT_SESSION = 1 # update when 2nd session begins
+
+
+def _vote_url(congress: int, session: int, vote_num: int) -> str:
+ return (
+ f"{VOTE_XML_BASE}/vote{congress}{session}/"
+ f"vote_{congress}_{session}_{vote_num:05d}.xml"
+ )
+
+
+def _parse_senate_vote(xml_text: str, source_url: str) -> Optional[dict]:
+ try:
+ root = ET.fromstring(xml_text)
+ except ET.ParseError as exc:
+ logger.error("votes_senate: XML parse error: %s", exc)
+ return None
+
+ def text(tag: str) -> str:
+ el = root.find(tag)
+ return el.text.strip() if el is not None and el.text else ""
+
+ vote_date = text("vote_date") # format varies; try common patterns
+ date_iso = ""
+ for fmt in ("%B %d, %Y, %I:%M %p", "%B %d, %Y", "%Y-%m-%d"):
+ try:
+ date_iso = datetime.strptime(vote_date, fmt).strftime("%Y-%m-%d")
+ break
+ except ValueError:
+ pass
+ if not date_iso:
+ date_iso = vote_date[:10] if len(vote_date) >= 10 else vote_date
+
+ counts = root.find("count")
+ yea = nay = present = 0
+ if counts is not None:
+ yea = int(counts.findtext("yeas", "0") or 0)
+ nay = int(counts.findtext("nays", "0") or 0)
+ present = int(counts.findtext("abstains", "0") or 0)
+
+ result = text("vote_result")
+
+ return {
+ "action_date": date_iso,
+ "yea": yea,
+ "nay": nay,
+ "present": present,
+ "result": result,
+ "source_url": source_url,
+ }
+
+
+def poll(
+ congress: int = CURRENT_CONGRESS,
+ session: int = CURRENT_SESSION,
+ max_new: int = 20,
+ db_path: Optional[str] = None,
+) -> int:
+ """
+ Fetch new Senate roll-call votes.
+
+ Returns
+ -------
+ Number of new vote rows stored.
+ """
+ conn = get_connection(db_path) if db_path else get_connection()
+ init_schema(conn)
+
+ prefix = f"{congress}-{session}-"
+ last_row = conn.execute(
+ "SELECT external_id FROM seen WHERE source=? AND external_id LIKE ? ORDER BY external_id DESC LIMIT 1",
+ (SOURCE, f"{prefix}%"),
+ ).fetchone()
+
+ last_num = int(last_row["external_id"].split("-")[2]) if last_row else 0
+
+ stored = 0
+ probe_num = last_num + 1
+
+ while stored < max_new:
+ external_id = f"{congress}-{session}-{probe_num:05d}"
+ if not is_new_event(conn, SOURCE, external_id, external_id):
+ probe_num += 1
+ continue
+
+ url = _vote_url(congress, session, probe_num)
+ try:
+ resp = requests.get(url, timeout=15)
+ except requests.RequestException as exc:
+ logger.error("votes_senate: request error %s: %s", url, exc)
+ break
+
+ if resp.status_code == 404:
+ logger.info("votes_senate: no vote %s (probe stopped)", external_id)
+ break
+ if not resp.ok:
+ logger.warning("votes_senate: HTTP %d for %s", resp.status_code, url)
+ break
+
+ try:
+ validate_source_url(url)
+ except Exception as exc:
+ logger.error("votes_senate: invalid source_url %s: %s", url, exc)
+ break
+
+ vote = _parse_senate_vote(resp.text, url)
+ if vote is None:
+ probe_num += 1
+ continue
+
+ conn.execute(
+ """
+ INSERT INTO votes (chamber, date, yea, nay, present, result, source_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ ("senate", vote["action_date"], vote["yea"], vote["nay"],
+ vote["present"], vote["result"], vote["source_url"]),
+ )
+
+ vote_row = conn.execute("SELECT last_insert_rowid() AS id").fetchone()
+ if vote_row:
+ conn.execute(
+ """
+ INSERT INTO events
+ (ref_type, ref_id, action_date, dollar_amount,
+ affected_estimate, action_type)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ ("vote", vote_row["id"], vote["action_date"], None, None, "floor_vote"),
+ )
+
+ mark_seen(conn, SOURCE, external_id, external_id)
+ stored += 1
+ probe_num += 1
+
+ conn.commit()
+ conn.close()
+ logger.info("votes_senate: stored %d new vote(s) (cong=%d s=%d)", stored, congress, session)
+ return stored
services/ingest/ingest/ranking.py +176 -0
@@ -0,0 +1,176 @@
+"""
+Feed ranking - neutrality rule #3.
+
+score(event) = f(actionDate, dollarAmount, affectedEstimate, actionType)
+
+FORBIDDEN inputs: news mentions, social shares, click engagement, trending,
+editorial pinning, virality of any kind.
+
+A unit test (tests/test_ranking.py) asserts that NO field other than the
+four above appears in this module's source code via AST inspection.
+"""
+
+import ast
+import math
+from datetime import date, datetime
+from typing import Optional
+
+
+# How many days back before recency weight decays to near-zero.
+HALF_LIFE_DAYS = 14
+
+# Dollar amount used to normalise - $1 trillion (federal scale).
+DOLLAR_SCALE = 1_000_000_000_000.0
+
+# Maximum people-affected normaliser - ~340 M (US population).
+AFFECTED_SCALE = 340_000_000.0
+
+# Base weights for action types (higher = more significant in the feed).
+ACTION_TYPE_WEIGHTS = {
+ # Legislative
+ "signed": 1.0,
+ "vetoed": 1.0,
+ "floor_vote": 0.9,
+ "cloture": 0.8,
+ "markup": 0.6,
+ "introduced": 0.3,
+ # Spending
+ "contract": 0.8,
+ "A": 0.8,
+ "B": 0.8,
+ "C": 0.8,
+ "D": 0.8,
+ "grant": 0.7,
+ "02": 0.7,
+ "03": 0.7,
+ "04": 0.7,
+ "05": 0.7,
+ "loan": 0.6,
+ "07": 0.6,
+ "08": 0.6,
+ "direct": 0.6,
+ "06": 0.6,
+ # Fiscal
+ "debt": 0.7,
+ "outlay": 0.6,
+ "deficit": 0.7,
+}
+
+# Component weights that sum to 1.0.
+W_RECENCY = 0.35
+W_DOLLAR = 0.35
+W_AFFECTED = 0.20
+W_TYPE = 0.10
+
+
+def _recency_score(actionDate: str) -> float:
+ """Exponential decay: 1.0 today, ~0.5 at HALF_LIFE_DAYS."""
+ try:
+ if isinstance(actionDate, str):
+ d = date.fromisoformat(actionDate[:10])
+ elif isinstance(actionDate, (date, datetime)):
+ d = actionDate if isinstance(actionDate, date) else actionDate.date()
+ else:
+ return 0.0
+ days_old = max(0, (date.today() - d).days)
+ return math.exp(-math.log(2) * days_old / HALF_LIFE_DAYS)
+ except (ValueError, TypeError):
+ return 0.0
+
+
+def _dollar_score(dollarAmount: Optional[float]) -> float:
+ """Log-normalised dollar score in [0, 1]."""
+ if not dollarAmount or dollarAmount <= 0:
+ return 0.0
+ return min(1.0, math.log1p(dollarAmount) / math.log1p(DOLLAR_SCALE))
+
+
+def _affected_score(affectedEstimate: Optional[int]) -> float:
+ """Linear normalised people-affected score in [0, 1]."""
+ if not affectedEstimate or affectedEstimate <= 0:
+ return 0.0
+ return min(1.0, affectedEstimate / AFFECTED_SCALE)
+
+
+def _type_score(actionType: str) -> float:
+ """Lookup-based weight for the action type."""
+ return ACTION_TYPE_WEIGHTS.get(str(actionType).lower(), 0.4)
+
+
+def score(
+ actionDate: str,
+ dollarAmount: Optional[float],
+ affectedEstimate: Optional[int],
+ actionType: str,
+) -> float:
+ """
+ Compute a ranking score in [0, 1] for a feed event.
+
+ Parameters
+ ----------
+ actionDate : ISO date string (YYYY-MM-DD) of the official action.
+ dollarAmount : Dollar magnitude of the action (None if not applicable).
+ affectedEstimate : Estimated number of people affected (None if unknown).
+ actionType : Official action type string (e.g. 'signed', 'contract').
+
+ Returns
+ -------
+ float in [0.0, 1.0] - higher is more prominent in the feed.
+ """
+ return (
+ W_RECENCY * _recency_score(actionDate)
+ + W_DOLLAR * _dollar_score(dollarAmount)
+ + W_AFFECTED * _affected_score(affectedEstimate)
+ + W_TYPE * _type_score(actionType)
+ )
+
+
+# ── Self-check ────────────────────────────────────────────────────────────────
+
+FORBIDDEN_RANKING_FIELDS = frozenset(
+ {
+ "views",
+ "shares",
+ "clicks",
+ "engagement",
+ "trending",
+ "viral",
+ "mentions",
+ "retweets",
+ "likes",
+ "comments",
+ "impressions",
+ "reach",
+ "popularity",
+ "social",
+ "news_count",
+ "media",
+ "editorial",
+ "pinned",
+ }
+)
+
+
+def assert_no_forbidden_fields() -> None:
+ """
+ Parse this module's AST and raise AssertionError if any forbidden
+ field name appears as a Name or Attribute node.
+ Exported so the unit test can call it directly.
+ """
+ import inspect
+
+ src = inspect.getsource(score)
+ # Also check helpers
+ for fn in (_recency_score, _dollar_score, _affected_score, _type_score):
+ src += inspect.getsource(fn)
+
+ tree = ast.parse(src)
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Name) and node.id in FORBIDDEN_RANKING_FIELDS:
+ raise AssertionError(
+ f"Forbidden field '{node.id}' found in ranking function source."
+ )
+ if isinstance(node, ast.Attribute) and node.attr in FORBIDDEN_RANKING_FIELDS:
+ raise AssertionError(
+ f"Forbidden field attribute '{node.attr}' found in ranking function source."
+ )
services/ingest/ingest/run.py +108 -0
@@ -0,0 +1,108 @@
+"""
+Docket ingest CLI.
+
+Usage
+-----
+ python -m ingest.run --once # run all pollers once and exit
+ python -m ingest.run --source usa # run only the USAspending poller
+
+Pollers
+-------
+ usaspending - real federal awards, no auth
+ congress - bills (requires DOCKET_CONGRESS_KEY; skips if unset)
+ govinfo - bill text / public laws (same key as congress)
+ treasury - debt + cash flow, no auth
+ votes_house - House roll-call XML, no auth
+ votes_senate - Senate roll-call XML, no auth
+ openstates - state bills (requires DOCKET_OPENSTATES_KEY; skips if unset)
+"""
+
+import argparse
+import logging
+import sys
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ stream=sys.stdout,
+)
+logger = logging.getLogger("ingest.run")
+
+
+def _run_all() -> dict:
+ """Run every poller once. Returns {source: count} of new rows stored."""
+ from ingest.pollers import congress, govinfo, openstates, treasury
+ from ingest.pollers import usaspending, votes_house, votes_senate
+
+ results = {}
+
+ logger.info("=== Docket ingest run starting ===")
+
+ results["usaspending"] = usaspending.poll()
+ results["congress"] = congress.poll()
+ results["govinfo"] = govinfo.poll()
+ results["treasury"] = treasury.poll()
+ results["votes_house"] = votes_house.poll()
+ results["votes_senate"]= votes_senate.poll()
+ results["openstates"] = openstates.poll()
+
+ logger.info("=== Ingest run complete ===")
+ for source, count in results.items():
+ logger.info(" %-15s → %d new row(s)", source, count)
+
+ return results
+
+
+def _run_one(source: str) -> int:
+ """Run a single named poller."""
+ from ingest.pollers import congress, govinfo, openstates, treasury
+ from ingest.pollers import usaspending, votes_house, votes_senate
+
+ pollers = {
+ "usaspending": usaspending.poll,
+ "congress": congress.poll,
+ "govinfo": govinfo.poll,
+ "treasury": treasury.poll,
+ "votes_house": votes_house.poll,
+ "votes_senate": votes_senate.poll,
+ "openstates": openstates.poll,
+ # short aliases
+ "usa": usaspending.poll,
+ "house": votes_house.poll,
+ "senate": votes_senate.poll,
+ }
+ fn = pollers.get(source.lower())
+ if fn is None:
+ logger.error("Unknown source: %r. Valid: %s", source, ", ".join(pollers))
+ sys.exit(1)
+ return fn()
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Docket ingest runner")
+ parser.add_argument(
+ "--once",
+ action="store_true",
+ help="Run all pollers once and exit.",
+ )
+ parser.add_argument(
+ "--source",
+ metavar="SOURCE",
+ help="Run only this poller (usaspending, congress, govinfo, treasury, "
+ "votes_house, votes_senate, openstates).",
+ )
+ args = parser.parse_args()
+
+ if not args.once and not args.source:
+ parser.print_help()
+ sys.exit(0)
+
+ if args.source:
+ count = _run_one(args.source)
+ logger.info("Done: %d new row(s) from %s", count, args.source)
+ else:
+ _run_all()
+
+
+if __name__ == "__main__":
+ main()
services/ingest/ingest/source_validator.py +74 -0
@@ -0,0 +1,74 @@
+"""
+Source URL validation - neutrality rule #1.
+
+Every stored fact MUST carry a source_url whose host is an allowlisted .gov
+domain. This module is the single enforcement point; call validate_source_url()
+before writing any row.
+"""
+
+from urllib.parse import urlparse
+
+
+# Explicit allowlist from DATA_SOURCES.md + NEUTRALITY.md.
+# The wildcard *.gov rule covers all remaining official government domains.
+ALLOWLISTED_HOSTS = frozenset(
+ {
+ "congress.gov",
+ "api.congress.gov",
+ "www.congress.gov",
+ "govinfo.gov",
+ "api.govinfo.gov",
+ "usaspending.gov",
+ "www.usaspending.gov",
+ "api.usaspending.gov",
+ "fiscaldata.treasury.gov",
+ "api.fiscaldata.treasury.gov",
+ "cbo.gov",
+ "www.cbo.gov",
+ "gpo.gov",
+ "www.gpo.gov",
+ "clerk.house.gov",
+ "senate.gov",
+ "www.senate.gov",
+ "house.gov",
+ "www.house.gov",
+ "treasury.gov",
+ "www.treasury.gov",
+ "omb.gov",
+ "www.omb.gov",
+ }
+)
+
+
+class SourceURLError(ValueError):
+ """Raised when a source_url host is not allowlisted."""
+
+
+def is_allowlisted(url: str) -> bool:
+ """
+ Return True if the URL's host is on the allowlist OR ends with .gov.
+ The *.gov wildcard in the spec covers all official government domains.
+ """
+ if not url:
+ return False
+ try:
+ host = urlparse(url).hostname or ""
+ except Exception:
+ return False
+ if host in ALLOWLISTED_HOSTS:
+ return True
+ # wildcard *.gov - covers state legislatures linked from OpenStates
+ return host.endswith(".gov")
+
+
+def validate_source_url(url: str) -> None:
+ """
+ Raise SourceURLError if the host is not allowlisted.
+ Call this before writing any row to the database.
+ """
+ if not is_allowlisted(url):
+ host = urlparse(url).hostname if url else "<empty>"
+ raise SourceURLError(
+ f"source_url host '{host}' is not an allowlisted .gov domain. "
+ f"url={url!r}"
+ )
services/ingest/tests/__init__.py +1 -0
@@ -0,0 +1 @@
+# Docket ingest tests
services/ingest/tests/test_ranking.py +145 -0
@@ -0,0 +1,145 @@
+"""
+Tests for ingest.ranking - neutrality rule #3.
+
+Key assertion: the ranking function references ONLY the four permitted fields
+(actionDate, dollarAmount, affectedEstimate, actionType) and no
+engagement/news/social inputs.
+"""
+
+import ast
+import inspect
+import textwrap
+
+import pytest
+
+from ingest.ranking import (
+ FORBIDDEN_RANKING_FIELDS,
+ assert_no_forbidden_fields,
+ score,
+ _recency_score,
+ _dollar_score,
+ _affected_score,
+ _type_score,
+)
+
+
+# ── Correctness ────────────────────────────────────────────────────────────────
+
+class TestScoreRange:
+ def test_score_in_unit_interval(self):
+ s = score("2026-06-25", 1_000_000, 50_000, "signed")
+ assert 0.0 <= s <= 1.0
+
+ def test_zero_dollar_zero_affected(self):
+ s = score("2026-06-25", None, None, "floor_vote")
+ assert 0.0 <= s <= 1.0
+
+ def test_older_action_scores_lower(self):
+ recent = score("2026-06-25", 1_000_000, 0, "contract")
+ old = score("2020-01-01", 1_000_000, 0, "contract")
+ assert recent > old, "More recent actions should score higher"
+
+ def test_higher_dollar_scores_higher(self):
+ small = score("2026-06-25", 1_000, None, "contract")
+ large = score("2026-06-25", 1_000_000_000, None, "contract")
+ assert large > small
+
+ def test_more_affected_scores_higher(self):
+ few = score("2026-06-25", None, 100, "introduced")
+ many = score("2026-06-25", None, 10_000_000, "introduced")
+ assert many > few
+
+ def test_high_weight_action_type(self):
+ signed = score("2026-06-25", None, None, "signed")
+ introduced = score("2026-06-25", None, None, "introduced")
+ assert signed > introduced
+
+ def test_unknown_action_type_does_not_crash(self):
+ s = score("2026-06-25", 500_000, None, "UNKNOWN_TYPE_XYZ")
+ assert 0.0 <= s <= 1.0
+
+ def test_bad_date_returns_low_score(self):
+ s = score("not-a-date", 1_000_000, 100_000, "signed")
+ # recency component is 0, others may contribute
+ assert 0.0 <= s <= 1.0
+
+ def test_returns_float(self):
+ assert isinstance(score("2026-06-25", 1_000_000, 50_000, "grant"), float)
+
+
+# ── Neutrality: no forbidden fields in ranking source ─────────────────────────
+
+class TestNoForbiddenFields:
+ """
+ Neutrality rule #3 enforcement.
+ The ranking helpers must NOT reference any engagement/news/social field.
+ """
+
+ def test_assert_no_forbidden_fields_passes(self):
+ """assert_no_forbidden_fields() must not raise on the current module."""
+ assert_no_forbidden_fields() # raises AssertionError if a forbidden field found
+
+ def _collect_names_from_source(self, fn) -> set:
+ src = inspect.getsource(fn)
+ tree = ast.parse(src)
+ names = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Name):
+ names.add(node.id)
+ elif isinstance(node, ast.Attribute):
+ names.add(node.attr)
+ return names
+
+ def test_score_function_uses_no_forbidden_names(self):
+ names = self._collect_names_from_source(score)
+ forbidden_found = names & FORBIDDEN_RANKING_FIELDS
+ assert not forbidden_found, (
+ f"score() references forbidden fields: {forbidden_found}"
+ )
+
+ def test_recency_score_uses_no_forbidden_names(self):
+ names = self._collect_names_from_source(_recency_score)
+ forbidden_found = names & FORBIDDEN_RANKING_FIELDS
+ assert not forbidden_found, (
+ f"_recency_score() references forbidden fields: {forbidden_found}"
+ )
+
+ def test_dollar_score_uses_no_forbidden_names(self):
+ names = self._collect_names_from_source(_dollar_score)
+ forbidden_found = names & FORBIDDEN_RANKING_FIELDS
+ assert not forbidden_found, (
+ f"_dollar_score() references forbidden fields: {forbidden_found}"
+ )
+
+ def test_affected_score_uses_no_forbidden_names(self):
+ names = self._collect_names_from_source(_affected_score)
+ forbidden_found = names & FORBIDDEN_RANKING_FIELDS
+ assert not forbidden_found, (
+ f"_affected_score() references forbidden fields: {forbidden_found}"
+ )
+
+ def test_type_score_uses_no_forbidden_names(self):
+ names = self._collect_names_from_source(_type_score)
+ forbidden_found = names & FORBIDDEN_RANKING_FIELDS
+ assert not forbidden_found, (
+ f"_type_score() references forbidden fields: {forbidden_found}"
+ )
+
+ def test_forbidden_field_would_be_caught(self):
+ """
+ Sanity-check: verify that the detection logic catches a fake function
+ that DOES reference a forbidden field.
+ """
+ # Define a function that uses a forbidden name, then parse it.
+ def _bad_ranking(actionDate, dollarAmount, affectedEstimate, actionType, engagement):
+ return engagement * dollarAmount # forbidden: 'engagement'
+
+ src = textwrap.dedent(inspect.getsource(_bad_ranking))
+ tree = ast.parse(src)
+ names = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Name):
+ names.add(node.id)
+ assert "engagement" in names & FORBIDDEN_RANKING_FIELDS, (
+ "Detection logic failed to find 'engagement' in bad function"
+ )
services/ingest/tests/test_source_url.py +174 -0
@@ -0,0 +1,174 @@
+"""
+Tests for ingest.source_validator - neutrality rule #1.
+
+Every stored fact MUST carry a source_url whose host is an allowlisted .gov domain.
+"""
+
+import sqlite3
+import tempfile
+import os
+
+import pytest
+
+from ingest.source_validator import (
+ ALLOWLISTED_HOSTS,
+ SourceURLError,
+ is_allowlisted,
+ validate_source_url,
+)
+from ingest.db import get_connection, init_schema
+
+
+# ── is_allowlisted() ──────────────────────────────────────────────────────────
+
+class TestIsAllowlisted:
+ def test_explicit_allowlist_hosts(self):
+ for host in ALLOWLISTED_HOSTS:
+ url = f"https://{host}/some/path"
+ assert is_allowlisted(url), f"Expected {host!r} to be allowlisted"
+
+ def test_any_gov_domain_is_allowed(self):
+ gov_urls = [
+ "https://www.usaspending.gov/award/CONT_AWD_N0001923C0001",
+ "https://congress.gov/bill/119th-congress/house-bill/1",
+ "https://fiscaldata.treasury.gov/datasets/debt-to-the-penny/",
+ "https://clerk.house.gov/evs/2026/ROLL_001.xml",
+ "https://www.senate.gov/legislative/LIS/roll_call_votes/vote1191/vote_119_1_00001.xml",
+ "https://leg.colorado.gov/bills/hb25-1001",
+ "https://legiscan.gov/somepage", # hypothetical .gov subdomain
+ "https://www.congress.gov/bill/119th-congress/senate-bill/99",
+ ]
+ for url in gov_urls:
+ assert is_allowlisted(url), f"Expected .gov URL to be allowed: {url}"
+
+ def test_non_gov_domains_rejected(self):
+ bad_urls = [
+ "https://www.nytimes.com/article/congress-bill",
+ "https://propublica.org/data",
+ "https://govtrack.us/congress/bills",
+ "https://ballotpedia.org/H.R._1",
+ "https://en.wikipedia.org/wiki/U.S._Congress",
+ "https://politico.com/story",
+ "https://api.example.com/bills",
+ "",
+ ]
+ for url in bad_urls:
+ assert not is_allowlisted(url), f"Expected {url!r} to be rejected"
+
+ def test_empty_string_rejected(self):
+ assert not is_allowlisted("")
+
+ def test_none_like_string_rejected(self):
+ assert not is_allowlisted("None")
+
+ def test_subdomain_gov_allowed(self):
+ assert is_allowlisted("https://api.congress.gov/v3/bill")
+ assert is_allowlisted("https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/debt_to_penny")
+
+ def test_gov_in_path_not_in_host_rejected(self):
+ # Host is .com even though "gov" appears in path
+ assert not is_allowlisted("https://example.com/gov/bills")
+
+
+# ── validate_source_url() ─────────────────────────────────────────────────────
+
+class TestValidateSourceUrl:
+ def test_valid_gov_url_does_not_raise(self):
+ validate_source_url("https://www.usaspending.gov/award/CONT_AWD_TEST")
+ validate_source_url("https://congress.gov/bill/119th-congress/house-bill/1")
+
+ def test_invalid_url_raises_source_url_error(self):
+ with pytest.raises(SourceURLError):
+ validate_source_url("https://www.reuters.com/article/xyz")
+
+ def test_empty_url_raises(self):
+ with pytest.raises(SourceURLError):
+ validate_source_url("")
+
+ def test_non_gov_subdomain_raises(self):
+ with pytest.raises(SourceURLError):
+ validate_source_url("https://api.example.io/bills")
+
+ def test_error_message_contains_host(self):
+ with pytest.raises(SourceURLError, match="nytimes.com"):
+ validate_source_url("https://www.nytimes.com/article/bill")
+
+
+# ── Database integration: every row must have an allowlisted source_url ───────
+
+class TestDatabaseSourceUrlConstraint:
+ """
+ Integration test: simulate writing rows to SQLite and verify that any row
+ whose source_url is not allowlisted would have been caught by validate_source_url
+ before insertion.
+ """
+
+ def _make_db(self):
+ tmp = tempfile.mktemp(suffix=".db")
+ conn = get_connection(tmp)
+ init_schema(conn)
+ return conn, tmp
+
+ def test_awards_with_valid_source_url_stored_successfully(self):
+ conn, tmp = self._make_db()
+ source_url = "https://www.usaspending.gov/award/CONT_AWD_TEST001"
+ validate_source_url(source_url) # must not raise
+ conn.execute(
+ "INSERT INTO awards (source, external_id, agency, recipient, amount, action_date, program, source_url) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ ("usaspending", "TEST001", "DoD", "ACME Corp", 1_000_000, "2026-06-25", "contract", source_url),
+ )
+ conn.commit()
+ row = conn.execute("SELECT source_url FROM awards WHERE external_id='TEST001'").fetchone()
+ assert row is not None
+ assert is_allowlisted(row["source_url"])
+ conn.close()
+ os.unlink(tmp)
+
+ def test_awards_with_invalid_source_url_blocked(self):
+ conn, tmp = self._make_db()
+ bad_url = "https://www.politico.com/award/XYZ"
+ with pytest.raises(SourceURLError):
+ validate_source_url(bad_url)
+ # If we get here the guard failed - forcibly fail
+ conn.execute(
+ "INSERT INTO awards (source, external_id, agency, recipient, amount, action_date, program, source_url) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ ("test", "BAD001", "Agency", "Bad Corp", 100, "2026-06-25", "contract", bad_url),
+ )
+ conn.close()
+ os.unlink(tmp)
+
+ def test_all_awards_from_usaspending_have_gov_source_url(self):
+ """
+ Simulate a batch of awards as the poller would produce them and confirm
+ every source_url passes the allowlist check.
+ """
+ internal_ids = [
+ "CONT_AWD_N0001923C0001",
+ "CONT_AWD_W912HV23C0001",
+ "ASST_NON_2023MU00009",
+ ]
+ for iid in internal_ids:
+ url = f"https://www.usaspending.gov/award/{iid}"
+ assert is_allowlisted(url), f"USAspending award URL failed allowlist: {url}"
+
+ def test_bills_from_congress_have_gov_source_url(self):
+ urls = [
+ "https://www.congress.gov/bill/119th-congress/house-bill/1",
+ "https://www.congress.gov/bill/119th-congress/senate-bill/99",
+ ]
+ for url in urls:
+ assert is_allowlisted(url)
+
+ def test_fiscal_from_treasury_has_gov_source_url(self):
+ url = "https://fiscaldata.treasury.gov/datasets/debt-to-the-penny/debt-to-the-penny"
+ assert is_allowlisted(url)
+
+ def test_house_votes_have_gov_source_url(self):
+ url = "https://clerk.house.gov/evs/2026/ROLL_001.xml"
+ assert is_allowlisted(url)
+
+ def test_senate_votes_have_gov_source_url(self):
+ url = "https://www.senate.gov/legislative/LIS/roll_call_votes/vote1191/vote_119_1_00001.xml"
+ assert is_allowlisted(url)
tailwind.config.ts +74 -0
@@ -0,0 +1,74 @@
+import type { Config } from "tailwindcss";
+
+/**
+ * Docket Tailwind theme - every value is mapped to a CSS custom property
+ * defined in app/globals.css. The token is the source of truth; Tailwind
+ * is the consumer. (docs/BRAND.md §1)
+ */
+const config: Config = {
+ darkMode: "class",
+ content: [
+ "./app/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./lib/**/*.{ts,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ ink: "var(--dk-ink)",
+ surface: "var(--dk-surface)",
+ graphite: "var(--dk-graphite)",
+ paper: "var(--dk-paper)",
+ ash: {
+ DEFAULT: "var(--dk-ash)",
+ 2: "var(--dk-ash-2)",
+ },
+ line: "var(--dk-line)",
+ signal: {
+ DEFAULT: "var(--dk-signal)",
+ dim: "var(--dk-signal-dim)",
+ tint: "var(--dk-signal-tint)",
+ },
+ // semantic - muted, never vivid (neutrality)
+ yea: "var(--dk-yea)",
+ nay: "var(--dk-nay)",
+ hold: "var(--dk-hold)",
+ // party - desaturated, equal weight
+ party: {
+ d: "var(--dk-party-d)",
+ r: "var(--dk-party-r)",
+ i: "var(--dk-party-i)",
+ },
+ },
+ fontFamily: {
+ display: ["var(--font-display)", "system-ui", "sans-serif"],
+ mono: ["var(--font-mono)", "ui-monospace", "monospace"],
+ prose: ["var(--font-prose)", "Georgia", "serif"],
+ },
+ letterSpacing: {
+ tightest: "-0.04em",
+ tighter: "-0.02em",
+ },
+ maxWidth: {
+ register: "64rem",
+ },
+ keyframes: {
+ pulseDot: {
+ "0%, 100%": { opacity: "1", transform: "scale(1)" },
+ "50%": { opacity: "0.35", transform: "scale(0.8)" },
+ },
+ fadeUp: {
+ "0%": { opacity: "0", transform: "translateY(4px)" },
+ "100%": { opacity: "1", transform: "translateY(0)" },
+ },
+ },
+ animation: {
+ pulseDot: "pulseDot 1.6s ease-in-out infinite",
+ fadeUp: "fadeUp 240ms ease-out both",
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
tsconfig.json +23 -0
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}