| @@ -0,0 +1,3 @@ | ||
| + | { | |
| + | "extends": "next/core-web-vitals" | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } | |
| + |
| @@ -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; | |
| + | } | |
| + | } |
| @@ -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.", | |
| + | }, | |
| + | ]; |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,5 @@ | ||
| + | import { redirect } from "next/navigation"; | |
| + | ||
| + | export default function RootPage() { | |
| + | redirect("/register"); | |
| + | } |
| @@ -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 | |
| + | ); | |
| + | } |
| @@ -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>; | |
| + | } |
| @@ -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>; | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | </> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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 ""; | |
| + | } | |
| + | } |
| @@ -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"; | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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(" "); | |
| + | } |
| @@ -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", | |
| + | }); | |
| + | } |
| @@ -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)"; | |
| + | } | |
| + | } |
| @@ -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); |
| @@ -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. |
| @@ -0,0 +1,6 @@ | ||
| + | /** @type {import('next').NextConfig} */ | |
| + | const nextConfig = { | |
| + | reactStrictMode: true, | |
| + | }; | |
| + | ||
| + | module.exports = nextConfig; |
| @@ -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" | |
| + | } | |
| + | } |
| @@ -0,0 +1,6 @@ | ||
| + | module.exports = { | |
| + | plugins: { | |
| + | tailwindcss: {}, | |
| + | autoprefixer: {}, | |
| + | }, | |
| + | }; |
| @@ -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> |
| @@ -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. |
| @@ -0,0 +1 @@ | ||
| + | # Docket ingest package |
| @@ -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), | |
| + | ) |
| @@ -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() |
| @@ -0,0 +1 @@ | ||
| + | # Docket pollers package |
| @@ -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 |
| @@ -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 |
| @@ -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 |
| @@ -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 |
| @@ -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 |
| @@ -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 |
| @@ -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 |
| @@ -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." | |
| + | ) |
| @@ -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() |
| @@ -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}" | |
| + | ) |
| @@ -0,0 +1 @@ | ||
| + | # Docket ingest tests |
| @@ -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" | |
| + | ) |
| @@ -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) |
| @@ -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; |
| @@ -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"] | |
| + | } |