| 1 | import type { Metadata } from "next"; |
| 2 | import Link from "next/link"; |
| 3 | import { Card, PageHeader, SectionHeader } from "@/components/ui"; |
| 4 | import { DataTable, type Column, type Row } from "@/components/data-table"; |
| 5 | import { getStates, type StateCoverage } from "@/lib/feed"; |
| 6 | import { clsx } from "@/lib/cx"; |
| 7 | import { formatDate } from "@/lib/format"; |
| 8 | |
| 9 | export const metadata: Metadata = { |
| 10 | title: "States", |
| 11 | description: |
| 12 | "Coverage for the 50 states, DC, and U.S. territories. State-legislative coverage is rolling out; gaps are recorded, not hidden. Open a state for its delegation and sponsored bills.", |
| 13 | }; |
| 14 | |
| 15 | export const revalidate = 300; |
| 16 | |
| 17 | |
| 18 | function coverageMeta(c: StateCoverage["confidence"]): { |
| 19 | label: string; |
| 20 | color: string; |
| 21 | } { |
| 22 | if (c === "ok") return { label: "Current", color: "var(--dk-yea)" }; |
| 23 | if (c === "stale") return { label: "Stale", color: "var(--dk-hold)" }; |
| 24 | return { label: "Coverage rolling out", color: "var(--dk-ash)" }; |
| 25 | } |
| 26 | |
| 27 | |
| 28 | const NON_STATE_CODES = new Set(["DC", "PR", "AS", "GU", "MP", "VI"]); |
| 29 | |
| 30 | function buildColumns(): Column[] { |
| 31 | return [ |
| 32 | { key: "state", header: "Name", cardTitle: true }, |
| 33 | { key: "confidence", header: "Coverage" }, |
| 34 | { key: "seen", header: "Last seen", hideOnCard: true }, |
| 35 | { key: "missing", header: "Not yet in record" }, |
| 36 | ]; |
| 37 | } |
| 38 | |
| 39 | function buildRows(entries: StateCoverage[]): Row[] { |
| 40 | return entries.map((s) => { |
| 41 | const m = coverageMeta(s.confidence); |
| 42 | return { |
| 43 | key: s.code, |
| 44 | cells: { |
| 45 | state: ( |
| 46 | <Link |
| 47 | href={`/states/${s.code}`} |
| 48 | className="focus-ring rounded-sm text-paper underline underline-offset-4 hover:text-paper" |
| 49 | > |
| 50 | <span className="mr-2 text-ash-2">{s.code}</span> |
| 51 | {s.name} |
| 52 | </Link> |
| 53 | ), |
| 54 | confidence: ( |
| 55 | <span className="inline-flex items-center gap-1.5" style={{ color: m.color }}> |
| 56 | <span |
| 57 | aria-hidden="true" |
| 58 | className="inline-block h-1.5 w-1.5 rounded-full" |
| 59 | style={{ backgroundColor: m.color }} |
| 60 | /> |
| 61 | {m.label} |
| 62 | </span> |
| 63 | ), |
| 64 | seen: ( |
| 65 | <span className="tabular-nums text-ash-2"> |
| 66 | {s.lastScrape ? formatDate(s.lastScrape) : "-"} |
| 67 | </span> |
| 68 | ), |
| 69 | missing: ( |
| 70 | <span className="uppercase text-ash-2"> |
| 71 | {s.missingTypes.length ? s.missingTypes.join(", ") : "-"} |
| 72 | </span> |
| 73 | ), |
| 74 | }, |
| 75 | }; |
| 76 | }); |
| 77 | } |
| 78 | |
| 79 | export default function StatesPage() { |
| 80 | const allJurisdictions = getStates(); |
| 81 | |
| 82 | const states50 = allJurisdictions.filter((s) => !NON_STATE_CODES.has(s.code)); |
| 83 | const dcAndTerritories = allJurisdictions.filter((s) => NON_STATE_CODES.has(s.code)); |
| 84 | |
| 85 | const ok = states50.filter((s) => s.confidence === "ok").length; |
| 86 | const stale = states50.filter((s) => s.confidence === "stale").length; |
| 87 | const rolling = states50.filter((s) => s.confidence === "missing").length; |
| 88 | |
| 89 | const columns = buildColumns(); |
| 90 | |
| 91 | return ( |
| 92 | <div |
| 93 | className="mx-auto w-full max-w-[860px] px-6 sm:px-8" |
| 94 | style={{ |
| 95 | paddingTop: "clamp(32px, 6vw, 56px)", |
| 96 | paddingBottom: "clamp(48px, 8vw, 80px)", |
| 97 | }} |
| 98 | > |
| 99 | {} |
| 100 | <nav aria-label="Breadcrumb" className="mb-10"> |
| 101 | <ol className="flex flex-wrap items-center gap-2" style={{ marginBottom: "12px" }}> |
| 102 | <li> |
| 103 | <Link |
| 104 | href="/" |
| 105 | className="focus-ring label no-underline hover:underline" |
| 106 | style={{ color: "var(--dk-signal)", fontSize: "12px", letterSpacing: "0.12em" }} |
| 107 | > |
| 108 | Home |
| 109 | </Link> |
| 110 | </li> |
| 111 | <li aria-hidden="true" className="label" style={{ color: "var(--dk-ash-2)", fontSize: "12px" }}>/</li> |
| 112 | <li |
| 113 | className="label" |
| 114 | style={{ color: "var(--dk-ash)", fontSize: "12px", letterSpacing: "0.12em" }} |
| 115 | aria-current="page" |
| 116 | > |
| 117 | States |
| 118 | </li> |
| 119 | </ol> |
| 120 | <Link |
| 121 | href="/" |
| 122 | className="focus-ring tap no-underline hover:underline" |
| 123 | style={{ |
| 124 | display: "inline-flex", |
| 125 | alignItems: "center", |
| 126 | gap: "8px", |
| 127 | fontFamily: "var(--font-mono), ui-monospace, monospace", |
| 128 | fontSize: "13px", |
| 129 | letterSpacing: "0.08em", |
| 130 | color: "var(--dk-signal)", |
| 131 | }} |
| 132 | > |
| 133 | ← Back to home |
| 134 | </Link> |
| 135 | </nav> |
| 136 | |
| 137 | <PageHeader |
| 138 | eyebrow="State register" |
| 139 | title="50 states + DC" |
| 140 | lead="Federal delegation and sponsored bills are on record for every state, DC, and U.S. territory. State-level legislative coverage is rolling out; gaps are recorded here, not hidden." |
| 141 | /> |
| 142 | |
| 143 | {} |
| 144 | <div className="grid gap-4 sm:grid-cols-3"> |
| 145 | <CoverageCard label="Current" count={ok} level="ok" /> |
| 146 | <CoverageCard label="Stale" count={stale} level="stale" /> |
| 147 | <CoverageCard label="Coverage rolling out" count={rolling} level="missing" /> |
| 148 | </div> |
| 149 | <p className="label mt-3 text-ash-2"> |
| 150 | Coverage counts above apply to the 50 states. DC and U.S. territories are listed |
| 151 | in their own section below. |
| 152 | </p> |
| 153 | |
| 154 | {} |
| 155 | <div className="mt-12"> |
| 156 | <div className="mb-6"> |
| 157 | <p className="label mb-2">50 states · federal delegation on record</p> |
| 158 | <h2 |
| 159 | className="display text-paper" |
| 160 | style={{ fontSize: "clamp(1.25rem, 4vw, 1.875rem)" }} |
| 161 | > |
| 162 | Coverage by state |
| 163 | </h2> |
| 164 | <p className="prose-lead mt-3 max-w-2xl text-ash"> |
| 165 | How much of each state's legislature is on record here - across bills, |
| 166 | votes, and sponsors. Federal delegation and bill sponsorships (from |
| 167 | congress.gov) are on record regardless. Open a state for its congressional |
| 168 | delegation. |
| 169 | </p> |
| 170 | </div> |
| 171 | |
| 172 | <DataTable |
| 173 | caption="Per-state coverage: name, coverage level, last scrape, data not yet in record." |
| 174 | cardLabel="Per-state coverage" |
| 175 | columns={columns} |
| 176 | rows={buildRows(states50)} |
| 177 | /> |
| 178 | </div> |
| 179 | |
| 180 | {} |
| 181 | <div className="mt-14"> |
| 182 | <div className="mb-6"> |
| 183 | <p className="label mb-2">DC & U.S. territories · not states</p> |
| 184 | <h2 |
| 185 | className="display text-paper" |
| 186 | style={{ fontSize: "clamp(1.25rem, 4vw, 1.875rem)" }} |
| 187 | > |
| 188 | District of Columbia & territories |
| 189 | </h2> |
| 190 | <p className="prose-lead mt-3 max-w-2xl text-ash"> |
| 191 | DC is a federal district. Puerto Rico, American Samoa, Guam, the Northern |
| 192 | Mariana Islands, and the U.S. Virgin Islands are U.S. territories. None are |
| 193 | states. Congressional delegation and federal data are included in the record. |
| 194 | </p> |
| 195 | </div> |
| 196 | |
| 197 | <DataTable |
| 198 | caption="DC and U.S. territory coverage: name, coverage level, last scrape, data not yet in record." |
| 199 | cardLabel="DC and territory coverage" |
| 200 | columns={columns} |
| 201 | rows={buildRows(dcAndTerritories)} |
| 202 | /> |
| 203 | </div> |
| 204 | </div> |
| 205 | ); |
| 206 | } |
| 207 | |
| 208 | function CoverageCard({ |
| 209 | label, |
| 210 | count, |
| 211 | level, |
| 212 | }: { |
| 213 | label: string; |
| 214 | count: number; |
| 215 | level: StateCoverage["confidence"]; |
| 216 | }) { |
| 217 | const m = coverageMeta(level); |
| 218 | return ( |
| 219 | <Card className="p-5"> |
| 220 | <p className="label mb-2">{label}</p> |
| 221 | <div className="flex items-baseline gap-2"> |
| 222 | <span |
| 223 | className="display text-paper" |
| 224 | style={{ fontSize: "clamp(1.75rem, 6vw, 1.875rem)" }} |
| 225 | > |
| 226 | {count} |
| 227 | </span> |
| 228 | <span className="label text-ash-2">states</span> |
| 229 | </div> |
| 230 | <span |
| 231 | className={clsx("mt-3 inline-block h-0.5 w-12 rounded-full")} |
| 232 | style={{ backgroundColor: m.color }} |
| 233 | aria-hidden="true" |
| 234 | /> |
| 235 | </Card> |
| 236 | ); |
| 237 | } |