// "Your {jurisdiction} impact" — the interactive guess-and-reveal money game, // opened as a popup modal from the home page money hook. EVERY figure is REAL, // from GET /api/local-finance (Census state & local government finances); nulls // render as "—"/omitted and are NEVER shown as 0 (CLAUDE.md: No Fabricated Data). // // The right column's "Grandkids forecast" panel is wired to REAL Opportunity // Atlas mobility data (GET /api/grandkid-outlook, Chetty et al.). It compares the // local commuting zone's child-income percentile to the national one for a chosen // parent-income bracket — NOT the prototype's fabricated 1978-vs-1992 cohort // slopegraph. When no commuting zone matched the city, or the matched cell has too // little data, we show ONLY the national value plus the API's honest `note`; we // never invent a local number. // // Honest gaps vs. the design prototype: // - When the requested city/county isn't found the API returns statewide // figures (matched===false); we surface that with an explicit note. import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Dialog, Transition } from '@headlessui/react' import { useQueries, useQuery } from '@tanstack/react-query' import { XMarkIcon } from '@heroicons/react/24/outline' import { fetchCombinedFinance, fetchLocalFinance, fetchPropertyTaxRate, fetchSalesTaxRate, type CombinedFinance, type CombinedGovernment, type LocalFinance, type LocalFinanceCategory, type PropertyTaxRate, type SalesTaxRate, } from '../api/localFinance' import { fetchGrandkidOutlook, type GrandkidOutlook as GrandkidOutlookData, } from '../api/grandkidOutlook' import { useLocation as useLocationContext, type LocationData } from '../contexts/LocationContext' import { resolveCoordsToLocation, resolveQueryToChoices, resolveZipToChoices } from '../utils/resolvePlace' // Match the design prototype's typography (loaded globally by HomeV9): Playfair // Display for headlines, Source Sans for body, IBM Plex Mono for mono labels. const FONT = { fontFamily: "'Source Sans 3', system-ui, sans-serif" } as const const SERIF = { fontFamily: "'Playfair Display', Georgia, serif" } as const const MONO = { fontFamily: "'IBM Plex Mono', ui-monospace, monospace" } as const // Teal-forward palette for the spending categories (repo convention, not the // prototype's raw hex). const CAT_PALETTE = [ '#1a6b6b', '#2a8576', '#e0723a', '#7a5cd0', '#2f6fb0', '#9a6b12', '#1d6b5f', '#c0432a', ] // Whole-dollar currency, no decimals: 1495.81 -> "$1,496", 150505000 -> "$150,505,000". function fmtDollars(n: number | null | undefined): string { if (n == null || Number.isNaN(n)) return '—' return `$${Math.round(n).toLocaleString('en-US')}` } // Compact dollars for tight tabular rows: 150505000 -> "$150.5M", 9300000 -> "$9.3M", // 4200 -> "$4.2K". Falls back to whole dollars under $1K. function fmtDollarsCompact(n: number | null | undefined): string { if (n == null || Number.isNaN(n)) return '—' const abs = Math.abs(n) if (abs >= 1_000_000_000) return `$${(n / 1_000_000_000).toFixed(1)}B` if (abs >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M` if (abs >= 1_000) return `$${(n / 1_000).toFixed(1)}K` return `$${Math.round(n).toLocaleString('en-US')}` } // Human label for a stacked government: "City of Tuscaloosa", "Tuscaloosa // County", "Tuscaloosa City Schools". function labelGovernment(g: CombinedGovernment): string { if (g.level === 'city') return `City of ${g.jurisdiction_name}` if (g.level === 'county') return `${g.jurisdiction_name} County` return `${g.jurisdiction_name} Schools` } function pct(n: number): string { return `${Math.round(n)}%` } // Pull a 4-digit year out of a number or a date-ish string ("2025-01-01" -> "2025") // for the wire/string form (CLAUDE.md: serialize a bare year as a string). null // when there's no usable year — we never fabricate one. function yearOf(v: string | number | null | undefined): string | null { if (v == null) return null const m = /(\d{4})/.exec(String(v)) return m ? m[1] : null } export interface MoneyGameModalProps { open: boolean onClose: () => void /** 2-letter state code. Optional: when absent (no known location), the modal's * first tab shows a "where's home?" ZIP gate instead of the bill. */ stateCode?: string city?: string county?: string /** Requested city/county label, for the city→state fallback note. */ requestedLabel?: string /** Which stage to land on once a place is resolved. Defaults to the bill * estimator; pass 'grandkids' to open straight on the Opportunity Atlas * income-mobility trends. */ initialStage?: 'estimate' | 'game' | 'grandkids' } // --------------------------------------------------------------------------- // Build the SAME set of categories the user guesses against and we reveal, with // shares RENORMALIZED to sum to 100% across exactly that shown set (fair // scoring). Only categories with a non-null share_pct are eligible. Top 6 are // kept individually; any remainder is bucketed into a single "Other" row so the // guessed set === the revealed set. // --------------------------------------------------------------------------- interface CategoryPart { /** real mart label, e.g. "Utilities" / "Other & Debt". */ label: string /** share of the whole budget, percent (renormalized across the shown set). */ share: number } interface GameCategory { category: string /** real renormalized share across the shown set, percent. */ actual: number /** real combined dollars for this category (Census `amount`), so the reveal * can show what the share is worth. null when the source amount is missing. */ amount: number | null /** Present only on the "Other" bucket: its real constituents, for the * drill-down. Display-only — never guessed, so it has no slider. */ breakdown?: CategoryPart[] } // The named guess sliders, in the design prototype's order. We always show these // (when present in the data) and fold every OTHER real category — Utilities, // Health & Welfare, Other & Debt, etc. — into a single "Other" slider whose real // constituents stay visible via an expandable drill-down (so nothing real is // hidden). `match` is the verbose census label in the mart; `label` is the short // prototype label we render. const NAMED_CATEGORIES: { match: string; label: string }[] = [ { match: 'Education', label: 'Education' }, { match: 'Public Safety', label: 'Public Safety' }, { match: 'Infrastructure & Highways', label: 'Infrastructure' }, { match: 'Parks & Recreation', label: 'Parks & Rec' }, { match: 'Administration & Government', label: 'Administration' }, ] function buildGameCategories(categories: LocalFinanceCategory[]): GameCategory[] { const eligible = categories.filter((c) => c.share_pct != null && (c.share_pct as number) > 0) if (eligible.length === 0) return [] // Pull the named service categories out in prototype order; everything left // over becomes the single "Other" bucket. We carry each category's REAL dollar // `amount` alongside its share so the reveal can show both. const named: { label: string; share: number; amount: number }[] = [] const usedRaw = new Set() for (const { match, label } of NAMED_CATEGORIES) { const hit = eligible.find((c) => c.category === match) if (hit) { named.push({ label, share: hit.share_pct as number, amount: hit.amount }) usedRaw.add(match) } } const rest = eligible.filter((c) => !usedRaw.has(c.category)) const total = named.reduce((s, n) => s + n.share, 0) + rest.reduce((s, c) => s + (c.share_pct as number), 0) if (total <= 0) return [] // Renormalize so the shown shares sum to exactly 100; dollar amounts stay REAL. const result: GameCategory[] = named.map((n) => ({ category: n.label, actual: (n.share / total) * 100, amount: n.amount > 0 ? n.amount : null, })) if (rest.length > 0) { const otherShare = rest.reduce((s, c) => s + (c.share_pct as number), 0) const otherAmount = rest.reduce((s, c) => s + (c.amount || 0), 0) const breakdown: CategoryPart[] = rest .map((c) => ({ label: c.category, share: ((c.share_pct as number) / total) * 100 })) .sort((a, b) => b.share - a.share) result.push({ category: 'Other', actual: (otherShare / total) * 100, amount: otherAmount > 0 ? otherAmount : null, breakdown, }) } return result } // Sliders reuse the design prototype's exact `.range-x` CSS (in index.css) — // 8px inset track + 18px hollow thumb whose ring color is set per-slider via // the `--tc` CSS variable. Unfilled track color is the prototype's #ddd8d3. const SLIDER_UNFILLED = '#ddd8d3' // One labelled estimate slider. `log` gives an exponential track (fine control // in the everyday range) so a $230K home isn't pinned to the far left of a $2M // slider — same trick as the design prototype. function EstRow({ label, value, min, max, step, onChange, display, log, }: { label: string value: number min: number max: number step: number onChange: (v: number) => void display: string log?: boolean }) { const toT = (v: number) => Math.round(1000 * (Math.log(v / min) / Math.log(max / min))) const fromT = (t: number) => { const raw = min * Math.pow(max / min, t / 1000) return Math.min(max, Math.max(min, Math.round(raw / step) * step)) } const filled = log ? toT(value) / 10 : ((value - min) / (max - min)) * 100 return (
{label} {display}
onChange(log ? fromT(Number(e.target.value)) : Number(e.target.value))} style={ { '--tc': '#0d9488', background: `linear-gradient(to right, #0d9488 ${filled}%, ${SLIDER_UNFILLED} ${filled}%)`, } as React.CSSProperties } className="range-x" aria-label={label} />
) } // Small conic-gradient donut for the bill split. function BillDonut({ parts }: { parts: { value: number; color: string }[] }) { const usable = parts.filter((p) => p.value > 0) const total = usable.reduce((s, p) => s + p.value, 0) if (total <= 0) return null let acc = 0 const stops = usable .map((p) => { const start = (acc / total) * 100 acc += p.value return `${p.color} ${start}% ${(acc / total) * 100}%` }) .join(', ') return (
) } // --------------------------------------------------------------------------- // STAGE 1: "Your bill" — a personal estimate that mirrors the design prototype, // every rate REAL: property tax = your home value × the local effective ACS // rate (/property-tax-rate); sales tax = your taxable spending × the real // combined state+local rate (/sales-tax-rate, Tax Foundation); fees are your // own input. Renters: property tax is embedded in rent and remitted by the // landlord, so we don't fabricate a pass-through — we say so and base the // visible bill on sales + fees. // --------------------------------------------------------------------------- // "Who collects it" — taxes COLLECTED per resident, by level of government. // Each segment is the REAL Census `taxes_per_capita` for that level (total taxes // that government collects ÷ its population), fetched from the same /api/local- // finance?level=… endpoint the spending drill-down uses. This is the government's // collections normalized per head — NOT the user's personalized bill — so it's // labelled as such. A level with no data (404 / null taxes_per_capita) is simply // omitted; we never fabricate a segment. const COLLECT_LEVELS = [ { level: 'state', label: 'State', color: '#9ca3af' }, { level: 'county', label: 'County', color: '#e0723a' }, { level: 'city', label: 'City', color: '#1a6b6b' }, { level: 'school_district', label: 'Schools', color: '#2f6fb0' }, ] as const function WhoCollectsBar({ open, stateCode, city, county, }: { open: boolean stateCode: string city?: string county?: string }) { const results = useQueries({ queries: COLLECT_LEVELS.map((l) => ({ queryKey: ['who-collects', l.level, stateCode, city, county], queryFn: () => fetchLocalFinance({ state: stateCode, city, county, level: l.level }), enabled: open && !!stateCode, staleTime: 10 * 60 * 1000, retry: false, })), }) const segments = COLLECT_LEVELS.flatMap((l, i) => { const value = results[i].data?.taxes_per_capita ?? null return value != null && value > 0 ? [{ label: l.label as string, color: l.color as string, value }] : [] }) // Honest empty state: nothing to show until at least one level reports real // per-capita taxes. (Don't render a half-bar while still loading.) if (results.some((r) => r.isLoading)) return null if (segments.length === 0) return null const total = segments.reduce((s, x) => s + x.value, 0) return (

Who collects it

{segments.map((s) => (
))}
{segments.map((s) => ( {s.label} {fmtDollars(s.value)} ))}

Taxes collected per resident, by level of government — Census Annual Survey of State & Local Government Finances. This is each government's collections per head, not your personal bill.

) } function YourBill({ open, stateCode, city, county, jurisdictionName, onContinue, }: { open: boolean stateCode: string city?: string county?: string jurisdictionName: string onContinue: () => void }) { const propQ = useQuery({ queryKey: ['property-tax-rate', stateCode, city, county], queryFn: () => fetchPropertyTaxRate({ state: stateCode, city, county }), enabled: open && !!stateCode, staleTime: 10 * 60 * 1000, retry: false, }) const salesQ = useQuery({ queryKey: ['sales-tax-rate', stateCode], queryFn: () => fetchSalesTaxRate(stateCode), enabled: open && !!stateCode, staleTime: 10 * 60 * 1000, retry: false, }) const propRate = propQ.data?.effective_property_tax_rate ?? null const median = propQ.data?.median_home_value ?? null const salesPct = salesQ.data?.combined_sales_tax_rate_pct ?? null const salesFrac = salesPct != null ? salesPct / 100 : null // The data vintage for each rate — REAL years from the API, shown so the bill // is honest about how current it is (no fabricated "current year"). const acsYear = yearOf(propQ.data?.acs_vintage_year) const salesYear = yearOf(salesQ.data?.as_of_date) const [own, setOwn] = useState(true) const [homeValue, setHomeValue] = useState(null) // The user's own figures — start UNSET (0) so we never show a fabricated // default. Home value is the one seeded default, and it's the REAL local ACS // median (a citeable figure), framed as "the median household — adjust it". const [spend, setSpend] = useState(0) const [fees, setFees] = useState(0) const [income, setIncome] = useState(0) useEffect(() => { if (median != null) setHomeValue((v) => (v == null ? median : v)) }, [median]) const hv = homeValue ?? median ?? 0 const homeMax = Math.max(1_000_000, Math.ceil(((median ?? 230_000) * 2.5) / 50_000) * 50_000) const propTax = own && propRate != null && hv > 0 ? hv * propRate : null const salesTax = salesFrac != null && spend > 0 ? spend * salesFrac : null // Only ever show line items backed by a real figure or the user's own input — // no $0 placeholder rows. const parts: { label: string; value: number; color: string }[] = [] if (propTax != null && propTax > 0) parts.push({ label: 'Property tax', value: propTax, color: '#1a6b6b' }) if (salesTax != null && salesTax > 0) parts.push({ label: 'Sales tax', value: salesTax, color: '#2a8576' }) if (fees > 0) parts.push({ label: 'Fees & other', value: fees, color: '#7fd0c4' }) const total = parts.reduce((s, p) => s + p.value, 0) const sharePct = income > 0 && total > 0 ? (total / income) * 100 : null return (
{/* Inputs */}
{[ { v: true, label: 'I own' }, { v: false, label: 'I rent' }, ].map((o) => ( ))}
{own ? ( ) : (

Renters pay property tax through rent — your landlord remits it. We don't fabricate that split, so your visible bill below is local sales tax + fees.

)} 0 ? fmtDollars(spend) : 'add yours'} /> 0 ? fmtDollars(fees) : 'add yours'} /> 0 ? fmtDollars(income) : 'add yours'} />
{/* Live result */}

You pay approximately

{total > 0 ? fmtDollars(total) : '—'}

{total > 0 && (

per year · {fmtDollars(total / 12)}/mo

)} {(acsYear || salesYear) && (

{acsYear ? `Property ${acsYear} ACS` : ''} {acsYear && salesYear ? ' · ' : ''} {salesYear ? `Sales ${salesYear}` : ''}

)}
{sharePct != null && (

That's {sharePct.toFixed(1)}% of your household income going to local government.

)}
{parts.map((p) => ( {p.label} · {fmtDollars(p.value)} ))}
{(spend === 0 || income === 0) && (

Starts with {own ? `the real property tax on ${jurisdictionName}'s median home` : 'your inputs'}. Add your spending, fees, and income above to build your full bill — we don't fill those in for you.

)}

{propRate != null && ( <>Property: {jurisdictionName}'s {(propRate * 100).toFixed(2)}% effective ACS rate {acsYear ? ` (${acsYear} Census ACS)` : ' (Census)'}. )} {salesPct != null && ( <>Sales: {salesPct.toFixed(2)}% combined state+local rate (Tax Foundation {salesYear ? `, ${salesYear}` : ''}). )} Spending, fees & income are your own.

{/* Who collects it — REAL per-resident tax collections by level. */}
) } // --------------------------------------------------------------------------- // The live "your guess" donut: a "?" placeholder until the user touches a // slider, then a conic split of the guessed categories with a faint remainder // slice standing in for the not-yet-guessed ones. // --------------------------------------------------------------------------- function GuessDonut({ game, guesses, touched, }: { game: GameCategory[] guesses: number[] touched: boolean[] }) { const anyTouched = touched.some(Boolean) if (!anyTouched) { return (
?
) } const touchedCount = touched.filter(Boolean).length const sum = guesses.reduce((s, g) => s + g, 0) const parts = game.map((_, i) => ({ value: touched[i] ? guesses[i] : 0, color: CAT_PALETTE[i % CAT_PALETTE.length], })) // Faint slice for the categories still left to guess, so the ring grows as // the user works through them. const remainder = touchedCount < game.length ? Math.max((sum * (game.length - touchedCount)) / Math.max(touchedCount, 1), 8) : 0 const allParts = remainder > 0 ? [...parts, { value: remainder, color: '#eef4f4' }] : parts const total = allParts.reduce((s, p) => s + p.value, 0) || 1 let acc = 0 const stops = allParts .map((p) => { const start = (acc / total) * 100 acc += p.value const end = (acc / total) * 100 return `${p.color} ${start}% ${end}%` }) .join(', ') return (
Your
guess
) } // Small labelled conic donut for the reveal comparison (your guess vs. real). function RevealDonut({ parts, label, accent, }: { parts: { pct: number; color: string }[] label: React.ReactNode accent?: boolean }) { const total = parts.reduce((s, p) => s + p.pct, 0) || 1 let acc = 0 const stops = parts .map((p) => { const start = (acc / total) * 100 acc += p.pct return `${p.color} ${start}% ${(acc / total) * 100}%` }) .join(', ') return (
{label}
) } // --------------------------------------------------------------------------- // Spending-level selector. "Combined" (default) keeps the merged donut + the // guessing game; the single-level options drill into one government's REAL // expenditure-by-function breakdown (GET /api/local-finance?level=…). A level // with no data 404s and is surfaced as an explicit empty state — NEVER a // fabricated number (CLAUDE.md: No Fabricated Data). // --------------------------------------------------------------------------- type SpendingLevel = 'combined' | 'city' | 'county' | 'state' | 'school_district' const SPENDING_LEVELS: { value: SpendingLevel; label: string }[] = [ { value: 'combined', label: 'Combined' }, { value: 'city', label: 'City' }, { value: 'county', label: 'County' }, { value: 'state', label: 'State' }, { value: 'school_district', label: 'School' }, ] const STEP_TABS = [ { key: 'estimate' as const, label: '1 · Your bill', short: '1 · Bill' }, { key: 'game' as const, label: '2 · The guessing game', short: '2 · Game' }, { key: 'grandkids' as const, label: '3 · The grandkids', short: '3 · Grandkids' }, ] // The phrase used in empty-state copy ("No City spending data…"). function levelNoun(level: Exclude): string { if (level === 'school_district') return 'school district' return level } // Segmented pill row of government levels. Accessible: a `tablist` of buttons // with `aria-selected`; a level whose query 404'd is greyed + disabled (its // `disabled` flag is set once we know there's no data for that level). function LevelSelector({ value, onChange, disabledLevels, }: { value: SpendingLevel onChange: (l: SpendingLevel) => void disabledLevels: Partial> }) { return (
{SPENDING_LEVELS.map((opt) => { const active = value === opt.value const disabled = !!disabledLevels[opt.value] return ( ) })}
) } // A compact conic donut for a single level's expenditure-by-function split, // with a legend list of REAL (category, amount, share) rows. Mirrors the // combined reveal's palette/typography. function SpendingDonut({ categories }: { categories: LocalFinanceCategory[] }) { const usable = categories.filter((c) => c.amount > 0) const total = usable.reduce((s, c) => s + c.amount, 0) if (total <= 0) return null let acc = 0 const stops = usable .map((c, i) => { const start = (acc / total) * 100 acc += c.amount return `${CAT_PALETTE[i % CAT_PALETTE.length]} ${start}% ${(acc / total) * 100}%` }) .join(', ') return (
) } // Drill into ONE government level's REAL expenditure-by-function breakdown. // 404 (or empty categories) → explicit "no data" state; we never substitute // another level's numbers. function LevelBreakdown({ open, level, stateCode, city, county, onNoData, }: { open: boolean level: Exclude stateCode: string city?: string county?: string /** Reported once we learn this level has no data (404), so the parent can * grey out the tab. */ onNoData: (level: Exclude) => void }) { const { data, isLoading, isError, error } = useQuery({ queryKey: ['local-finance-level', level, stateCode, city, county], queryFn: () => fetchLocalFinance({ state: stateCode, city, county, level }), enabled: open && !!stateCode, staleTime: 5 * 60 * 1000, retry: false, }) // A 404 from the API means "no data for this level" — surface it, don't retry // forever, and let the parent disable the tab. const status = (error as { response?: { status?: number } } | undefined)?.response?.status const is404 = status === 404 useEffect(() => { if (is404) onNoData(level) }, [is404, level, onNoData]) if (isLoading) { return (
{[0, 1, 2, 3].map((r) => (
))}
) } const noData = is404 || !data || data.categories.filter((c) => c.amount > 0).length === 0 if (isError && !is404) { return (
We couldn't load {levelNoun(level)} spending right now. Please try again in a moment.
) } if (noData) { return (

No {levelNoun(level)} spending data for this location.

We only show figures that trace to a real government-finance record — so there's nothing to display for this level here. Try another level above.

) } // Sort by amount desc so the donut and list read top-down. const cats = [...data.categories].filter((c) => c.amount > 0).sort((a, b) => b.amount - a.amount) return (

{data.jurisdiction_name} — where it goes

FY {data.fiscal_year}
{/* Statewide-fallback honesty note (matched===false). */} {!data.matched && (

No exact {levelNoun(level)} match for this location — showing the statewide {levelNoun(level)} figures.

)}
{data.direct_expenditure != null && data.direct_expenditure > 0 && (
Total direct expenditure {fmtDollars(data.direct_expenditure)}
)} {cats.map((c, i) => (
{c.category} {c.share_pct != null && ( {pct(c.share_pct)} )} {fmtDollars(c.amount)}
))}

{data.source} {data.note ? ` · ${data.note}` : ''}

) } // --------------------------------------------------------------------------- // LEFT: the guessing game. Sliders start empty; the user must drag every // category before revealing, then each row shows the real share and how far // off they were. Categories & shares are REAL (Census), never fabricated. // --------------------------------------------------------------------------- function GuessingGame({ placeName, governments, game, revealed, onReveal, guesses, setGuesses, touched, setTouched, }: { placeName: string governments: CombinedGovernment[] game: GameCategory[] revealed: boolean onReveal: () => void guesses: number[] setGuesses: (g: number[]) => void touched: boolean[] setTouched: (t: boolean[]) => void }) { const [hintDismissed, setHintDismissed] = useState(false) // The "Other" slider can be expanded to reveal its real constituents (read-only). const [otherExpanded, setOtherExpanded] = useState(false) // Normalize the guesses to sum to 100 for display/scoring (auto-balance). const guessTotal = guesses.reduce((s, g) => s + g, 0) const normGuess = (i: number): number => guessTotal > 0 ? (guesses[i] / guessTotal) * 100 : 0 const setOne = (i: number, v: number) => { const next = [...guesses] next[i] = v setGuesses(next) if (!touched[i]) { const t = [...touched] t[i] = true setTouched(t) } } const touchedCount = touched.filter(Boolean).length const allGuessed = touchedCount === game.length && guesses.some((v) => v > 0) const firstUntouched = game.findIndex((_, i) => !touched[i]) // Real top category after reveal. const top = useMemo(() => { if (game.length === 0) return null return [...game].sort((a, b) => b.actual - a.actual)[0] }, [game]) const schoolGov = governments.find((g) => g.level === 'school_district') return (

The guessing game

{hintDismissed && ( Slide your gut feeling, then reveal. )}
{/* Dismissible scoring explainer — compact one-liner so the sliders stay above the fold. */} {!hintDismissed && (

How scoring works: drag all {game.length} to your gut feeling — they auto-balance to 100%. On reveal you score against the real adopted budget.

)} {/* Guessing: donut + sliders (hidden once revealed). */} {!revealed && ( <>
{game.map((c, i) => { const color = CAT_PALETTE[i % CAT_PALETTE.length] const fill = touched[i] ? Math.round(guesses[i]) : 0 return (
{c.category} {i === firstUntouched && ( drag to guess → )} {touched[i] ? ( {pct(normGuess(i))} ) : ( ? )}
setOne(i, Number(e.target.value))} style={ { '--tc': color, background: `linear-gradient(to right, ${color} ${fill}%, ${SLIDER_UNFILLED} ${fill}%)`, } as React.CSSProperties } className="range-x" aria-label={`Your guess for ${c.category}`} /> {/* Read-only drill-down for the "Other" bucket. */} {c.breakdown && c.breakdown.length > 0 && (
{otherExpanded && (
    {c.breakdown.map((part) => (
  • {part.label} included
  • ))}
)}
)}
) })}
)} {/* Reveal: your guess vs the real budget — two donuts + grow-in bars with a vertical guess marker. */} {revealed && (
({ pct: guesses[i], color: CAT_PALETTE[i % CAT_PALETTE.length] }))} label={<>YOUR
GUESS} /> ({ pct: c.actual, color: CAT_PALETTE[i % CAT_PALETTE.length] }))} label="REAL" accent />
{game.map((c, i) => { const g = normGuess(i) const d = Math.round(g - c.actual) const color = CAT_PALETTE[i % CAT_PALETTE.length] return (
{c.category} {pct(c.actual)} {c.amount != null && ( {fmtDollarsCompact(c.amount)}/yr )} = 8 ? '#9a6b12' : '#5d7d7d' }}> {Math.abs(d) <= 3 ? '✓ close' : d > 0 ? `${Math.abs(d)} high` : `${Math.abs(d)} low`}
) })}

Bar = real budget · | = your guess

{top && (

{pct(top.actual)} of every local dollar goes to {top.category} alone — every year.

)} {/* These are REAL combined Census figures — city + county + school district stacked. That's why Education is a big slice now. */}

This stacks {placeName}'s city, county {schoolGov ? <> and school district ({schoolGov.jurisdiction_name} Schools) : null} budgets — the full local government you fund (U.S. Census). “Administration” also folds in general spending the Census doesn't classify elsewhere.

)}
) } // Prototype grade ladder + score color for the accuracy readout (used by the // stage-2 score box after reveal). function gradeFor(score: number): string { if (score >= 90) return 'Civic genius' if (score >= 75) return 'Sharp eye' if (score >= 60) return 'Not bad' if (score >= 40) return 'Most people miss this' return 'Exactly why we built this' } function scoreColor(score: number): string { if (score >= 75) return '#3f8f2e' if (score >= 50) return '#9a6b12' return '#c0432a' } // --------------------------------------------------------------------------- // "Grandkids forecast" — real Opportunity Atlas intergenerational mobility // for the modal's location. For kids whose parents sat at the selected income // bracket, what adult income percentile did they reach? We compare the local // commuting zone to the U.S. on a 0–100 percentile scale. Every number is a real // API value; when there's no local cell we show only the national one + the note. // --------------------------------------------------------------------------- // Selector options — REAL Opportunity Atlas dimensions (value -> API code). const PARENT_OPTIONS: [string, string][] = [ ['low', 'Low'], ['middle', 'Middle'], ['high', 'High'], ] const RACE_OPTIONS: [string, string][] = [ ['pooled', 'All'], ['black', 'Black'], ['white', 'White'], ['hisp', 'Hispanic'], ['asian', 'Asian'], ] const GENDER_OPTIONS: [string, string][] = [ ['pooled', 'All'], ['female', 'Female'], ['male', 'Male'], ] // Prototype-style vertical dot-column selector (radio dots). function DotColumn({ title, options, active, onSelect, }: { title: string options: [string, string][] active: string onSelect: (v: string) => void }) { return (
{title}
{options.map(([value, label]) => { const isActive = active === value return ( ) })}
) } // English ordinal for a percentile rank, so a bare "31" reads as "31st" (a // position on the 0–100 ladder) and never as a dollar amount. function ordinal(n: number): string { const v = Math.round(n) const rem100 = v % 100 if (rem100 >= 11 && rem100 <= 13) return `${v}th` switch (v % 10) { case 1: return `${v}st` case 2: return `${v}nd` case 3: return `${v}rd` default: return `${v}th` } } // REAL local-vs-U.S. slope on a 0–100 child-income-rank scale. (The Atlas is a // single cohort, so we compare place-vs-nation — not the prototype's invented // 1978-vs-1992 cohorts.) function SlopeChart({ localPct, natPct, localLabel, }: { localPct: number | null natPct: number localLabel: string }) { const W = 320 const H = 134 const topY = 16 const botY = 104 const y = (p: number) => botY - (Math.max(0, Math.min(100, p)) / 100) * (botY - topY) const xUS = 66 const xLoc = 232 const short = localLabel.length > 14 ? localLabel.slice(0, 13) + '…' : localLabel return ( {/* Y-axis unit so the bare 25/50/75 ticks read as a rank, not dollars. */} PCTILE {[25, 50, 75].map((g) => ( {g} ))} {localPct != null ? ( <> {ordinal(localPct)} · {short} {ordinal(natPct)} · U.S. ) : ( <> U.S. avg {ordinal(natPct)} )} U.S. AVG {short.toUpperCase()} ) } function GrandkidsForecast({ open, stateCode, city, }: { open: boolean stateCode: string city?: string }) { const [parentIncome, setParentIncome] = useState('low') const [race, setRace] = useState('pooled') const [gender, setGender] = useState('pooled') const { data, isLoading, isError } = useQuery({ queryKey: ['grandkid-outlook', stateCode, city, parentIncome, race, gender], queryFn: () => fetchGrandkidOutlook({ state: stateCode, city, parent_income: parentIncome, race, gender }), enabled: open && !!stateCode, staleTime: 10 * 60 * 1000, }) // Real national + (optional) local percentiles — only ever a real API value. const nat = data?.national const natPct = nat && nat.available && typeof nat.child_percentile === 'number' ? nat.child_percentile : null const local = data?.local const localPct = local && local.available && typeof local.child_percentile === 'number' ? local.child_percentile : null const hasLocal = localPct != null const localLabel = data?.cz_name || data?.scope_label || 'Your area' const blank = isLoading || isError || !data || natPct == null const diff = !blank && hasLocal ? (localPct as number) - (natPct as number) : null const raceLabel = RACE_OPTIONS.find(([v]) => v === race && v !== 'pooled')?.[1] const genderLabel = GENDER_OPTIONS.find(([v]) => v === gender && v !== 'pooled')?.[1] const demoQual = [raceLabel, genderLabel].filter(Boolean).join(' · ') const verdict = blank ? 'Grandkids forecast' : !hasLocal ? 'Grandkids forecast' : diff != null && Math.abs(diff) < 1 ? `Kids raised in ${localLabel} land about the U.S. average` : diff != null && diff > 0 ? `Better off: kids raised in ${localLabel} reach a higher income rank than the U.S. average` : `Tougher odds: kids raised in ${localLabel} reach a lower income rank than the U.S. average` return (
{/* Teal verdict header (real numbers only). */}

{verdict}

Adult income rank for kids with {parentIncome}-income parents {demoQual ? ` · ${demoQual}` : ''} · vs the U.S.

{/* Dot-column selectors — REAL Atlas dimensions. */}
{/* Chart — dashed "?" while blank, else the local-vs-U.S. slope. */}
{blank ? (
?
) : ( <>

0 = bottom · 100 = top of the U.S. income ladder

)}
{/* Notes — honest about missing local cells + provenance. */}
{isError && (

We couldn't load mobility data right now. Try again, or change a filter.

)} {!blank && !hasLocal && (

{local == null ? 'No local mobility data matched to this place for this group yet — showing the U.S. baseline.' : `Not enough local data for ${localLabel} in this group — showing the U.S. baseline.`}

)} {!blank && data?.note && (

{data.note}

)} {!blank && data?.source && (

Source:{' '} {data.source_url ? ( {data.source} ) : ( data.source )}

)}
) } // --------------------------------------------------------------------------- // Loading skeleton. // --------------------------------------------------------------------------- function ModalSkeleton() { return (
{[0, 1].map((col) => (
{[0, 1].map((card) => (
{[0, 1, 2, 3].map((r) => (
))}
))}
))}
) } // =========================================================================== // The modal. // =========================================================================== // "First — where's home?" — the in-modal location gate (tab 1) shown when the // modal opens without a known place. The ZIP/coords resolve to a REAL place via // the shared /api/geocode helpers (never fabricated); a ZIP that spans cities / // city-vs-county offers the real choices, since city taxes stack on the county's. // The location-entry mode in the gate: a 5-digit ZIP, a city/county name, or a // full street address. All three resolve to a REAL place via /api/geocode. type GateMode = 'zip' | 'place' | 'address' function WhereIsHome({ onResolved }: { onResolved: (loc: LocationData) => void }) { const [mode, setMode] = useState('zip') const [zip, setZip] = useState('') // Free-text query for the 'place' (city/county) and 'address' modes. const [text, setText] = useState('') const [busy, setBusy] = useState(false) const [locating, setLocating] = useState(false) const [error, setError] = useState(null) const [choices, setChoices] = useState<{ label: string; loc: LocationData }[] | null>(null) const zipValid = /^\d{5}$/.test(zip) const textValid = text.trim().length >= 2 const inputValid = mode === 'zip' ? zipValid : textValid const needsChoice = !!choices && choices.length > 1 const switchMode = (next: GateMode) => { setMode(next) setError(null) setChoices(null) } const resolveZip = async () => { setError(null) setChoices(null) setBusy(true) try { const opts = await resolveZipToChoices(zip) if (opts.length === 0) { setError("We couldn't find that ZIP. Try a city/county, an address, or your location.") } else if (opts.length === 1) { onResolved(opts[0].loc) } else { setChoices(opts) } } catch { setError("We couldn't look up that ZIP right now. Please try again.") } finally { setBusy(false) } } // City/county name OR full address — both go through the same geocoder. const resolveText = async () => { setError(null) setChoices(null) setBusy(true) try { const opts = await resolveQueryToChoices(text) if (opts.length === 0) { setError( mode === 'address' ? "We couldn't find that address. Try adding the city and state, or use a ZIP." : "We couldn't find that place. Try \"City, ST\" or a county name.", ) } else if (opts.length === 1) { onResolved(opts[0].loc) } else { setChoices(opts) } } catch { setError("We couldn't look that up right now. Please try again.") } finally { setBusy(false) } } const handleShow = () => { if (busy || needsChoice || !inputValid) return if (mode === 'zip') void resolveZip() else void resolveText() } const useMyLocation = () => { setError(null) if (!navigator.geolocation) { setError('Location services are unavailable. Enter your ZIP instead.') return } setLocating(true) navigator.geolocation.getCurrentPosition( async ({ coords }) => { try { const loc = await resolveCoordsToLocation(coords.latitude, coords.longitude) if (!loc) { setError("We couldn't pin your location. Enter your ZIP instead.") return } onResolved(loc) } catch { setError("We couldn't pin your location. Enter your ZIP instead.") } finally { setLocating(false) } }, () => { setLocating(false) setError("We couldn't access your location. Enter your ZIP instead.") }, { timeout: 8000 }, ) } return (

First — where's home?

{mode === 'zip' ? 'Every town reaches into your pocket a little differently. Just the ZIP — nothing stored.' : mode === 'place' ? 'Type your city or county — we’ll find your community. Nothing stored.' : 'Type your full address — we only use it to find your community. Nothing stored.'}

{mode === 'zip' ? ( { setZip(e.target.value.replace(/\D/g, '').slice(0, 5)) setError(null) setChoices(null) }} onKeyDown={(e) => e.key === 'Enter' && handleShow()} placeholder="e.g. 35401" inputMode="numeric" autoFocus className="w-40 rounded-full border-[1.5px] px-4 py-3 text-center text-[16px] tracking-[0.12em] outline-none transition-colors" style={{ ...MONO, borderColor: zipValid ? '#1a6b6b' : '#d4e8e8' }} /> ) : ( { setText(e.target.value) setError(null) setChoices(null) }} onKeyDown={(e) => e.key === 'Enter' && handleShow()} placeholder={mode === 'place' ? 'e.g. Tuscaloosa, AL' : 'e.g. 123 Main St, Tuscaloosa, AL'} autoFocus className="w-72 max-w-full rounded-full border-[1.5px] px-4 py-3 text-center text-[15px] outline-none transition-colors" style={{ ...FONT, borderColor: textValid ? '#1a6b6b' : '#d4e8e8' }} /> )}
{/* Real geography: this ZIP spans places / city limits, and city rates stack on the county's, so the choice changes the bill. */} {needsChoice && choices && (

{mode === 'zip' ? `${zip} crosses jurisdiction lines — where's home? (City taxes stack on the county's.)` : 'We found a few matches — which is home?'}

{choices.map((c, i) => ( ))}
)} {error && (

{error}

)} {/* Other ways to find home — city/county or full address, both resolved by the same real geocoder. */}
{mode !== 'zip' && ( )} {mode !== 'place' && ( )} {mode !== 'address' && ( )}
) } export default function MoneyGameModal({ open, onClose, // For the BILL/GAME entries these are accepted but NOT used to seed the scope — // the "where's home?" gate always runs first (below). For the 'grandkids' entry // they DO seed the scope so we open straight on the Opportunity Atlas forecast. stateCode, city, county, requestedLabel, initialStage = 'estimate', }: MoneyGameModalProps) { const { setLocation } = useLocationContext() // The place the modal is scoped to, filled by the "where's home?" gate (tab 1). // For the BILL estimator we reset this to null on every open so the gate ALWAYS // shows first — taxes are intensely local ("every town reaches into your pocket // a little differently"), so we make the user confirm home before showing a bill. // For the income-mobility entry ("View income mobility trends" → initialStage // 'grandkids'), the user asked specifically for the Opportunity Atlas and we // already know enough to scope it (state + optional city). So when a place is // known via props we seed it and skip the gate, opening straight on the // forecast for that place instead of a redundant "where's home?" prompt. const [place, setPlace] = useState(null) const prevOpen = useRef(false) // Reset scope only when the modal *opens*, not when location props change mid-flow. // onResolvePlace → setLocation updates parent props (city/county); re-running this // on those deps was wiping the user's ZIP choice (e.g. "outside city limits"). useEffect(() => { const justOpened = open && !prevOpen.current prevOpen.current = open if (!justOpened) return if (initialStage === 'grandkids' && stateCode) { setPlace({ state: stateCode, city: city ?? '', county: county ?? '' }) } else { setPlace(null) // bill / game: always start at the gate } }, [open, initialStage, stateCode, city, county]) const scopeState = place?.state const scopeCity = place?.city || undefined const scopeCounty = place?.county || undefined const hasPlace = !!scopeState const onResolvePlace = useCallback( (loc: LocationData) => { setPlace(loc) setLocation(loc) // persist the choice site-wide, like the home banner }, [setLocation], ) // Re-open the "where's home?" gate so a user with an already-known place (e.g. // a previously cached location) can switch to a different ZIP. Only clears the // modal's scope — the site-wide saved location is left untouched until they // resolve a new one via the gate (which calls onResolvePlace). const changePlace = useCallback(() => setPlace(null), []) // Combined city + county + school-district budget — the full local government // a resident funds, so the guessing game's "Education" reflects real K-12. const { data, isLoading, isError } = useQuery({ queryKey: ['combined-finance', scopeState, scopeCity, scopeCounty], queryFn: () => fetchCombinedFinance({ state: scopeState as string, city: scopeCity, county: scopeCounty }), enabled: open && hasPlace, staleTime: 5 * 60 * 1000, }) const game = useMemo(() => (data ? buildGameCategories(data.categories) : []), [data]) // Guess sliders start empty (0 = "not yet guessed"); the user must drag each // category before they can reveal. `touched` tracks which have been moved. const [guesses, setGuesses] = useState([]) const [touched, setTouched] = useState([]) const [revealed, setRevealed] = useState(false) // Prototype's 3-stage flow: 1 your bill → 2 the guessing game → 3 the grandkids. const [stage, setStage] = useState<'estimate' | 'game' | 'grandkids'>('estimate') // Which government level's "where it goes" breakdown is shown in stage 2. // 'combined' (default) keeps the merged donut + the guessing game; the others // drill into one government via /api/local-finance?level=… const [spendingLevel, setSpendingLevel] = useState('combined') // Levels we've learned have no data (404) — greyed out in the selector. const [disabledLevels, setDisabledLevels] = useState>>({}) const markLevelNoData = useCallback((l: Exclude) => { setDisabledLevels((prev) => (prev[l] ? prev : { ...prev, [l]: true })) }, []) // Reset the game whenever it (re)opens or the category set changes. useEffect(() => { setGuesses(game.map(() => 0)) setTouched(game.map(() => false)) setRevealed(false) setStage(initialStage) setSpendingLevel('combined') setDisabledLevels({}) }, [open, game, initialStage]) // Score = 100 - totalError/2, where totalError sums |normalizedGuess - actual| // across the shown set (same formula as the prototype). Only after reveal. const scoreInfo = useMemo<{ score: number; totalError: number } | null>(() => { if (!revealed || game.length === 0 || guesses.length !== game.length) return null const guessTotal = guesses.reduce((s, g) => s + g, 0) if (guessTotal <= 0) return null const totalError = game.reduce((sum, c, i) => { const ng = (guesses[i] / guessTotal) * 100 return sum + Math.abs(ng - c.actual) }, 0) return { score: Math.max(0, Math.min(100, 100 - totalError / 2)), totalError } }, [revealed, game, guesses]) // Human label for the scoped place (the user's CHOSEN place, else the matched // jurisdiction). Drives the subtitle; the title stays place-agnostic. const placeName = scopeCity || scopeCounty || requestedLabel || data?.jurisdiction_name || null const title = 'Your money, mapped' // ── Stage 3: The grandkids (Opportunity Atlas income-mobility forecast) ── // Extracted so it can render independently of the combined-finance query: // GrandkidsForecast owns its own data/loading/error states, so the income- // mobility entry point ("View income mobility trends") must NOT be gated behind // — or blocked by a failure of — the tax-bill finance fetch. const grandkidsStage = (
) return ( ) }