Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| // "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<string>() | |
| 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 ( | |
| <div className="mb-3"> | |
| <div className="mb-1 flex items-baseline justify-between gap-2 text-[13px]" style={FONT}> | |
| <span className="min-w-0 truncate font-medium text-[#0f2b2b]">{label}</span> | |
| <span className="shrink-0 font-semibold tabular-nums text-[#0f766e]">{display}</span> | |
| </div> | |
| <input | |
| type="range" | |
| min={log ? 0 : min} | |
| max={log ? 1000 : max} | |
| step={log ? 1 : step} | |
| value={log ? toT(value) : value} | |
| onChange={(e) => 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} | |
| /> | |
| </div> | |
| ) | |
| } | |
| // 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 ( | |
| <div className="relative h-[84px] w-[84px] shrink-0 rounded-full" style={{ background: `conic-gradient(${stops})` }} aria-hidden> | |
| <div className="absolute inset-[24%] rounded-full bg-[#fafaf9]" /> | |
| </div> | |
| ) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 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 ( | |
| <div className="mt-5 rounded-2xl border border-[#d4e8e8] bg-white p-4 md:p-5"> | |
| <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#5d7d7d]" style={MONO}> | |
| Who collects it | |
| </p> | |
| <div className="mt-2.5 flex h-3.5 w-full overflow-hidden rounded-full bg-[#eef4f4]"> | |
| {segments.map((s) => ( | |
| <div | |
| key={s.label} | |
| style={{ width: `${(s.value / total) * 100}%`, backgroundColor: s.color }} | |
| title={`${s.label} Β· ${fmtDollars(s.value)} per resident`} | |
| /> | |
| ))} | |
| </div> | |
| <div className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1"> | |
| {segments.map((s) => ( | |
| <span key={s.label} className="flex items-center gap-1.5 text-[12px] text-[#56635e]" style={FONT}> | |
| <span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: s.color }} /> | |
| <span className="font-semibold text-[#0f2b2b]">{s.label}</span> {fmtDollars(s.value)} | |
| </span> | |
| ))} | |
| </div> | |
| <p className="mt-2 text-[10.5px] leading-relaxed text-[#5d7d7d]" style={FONT}> | |
| 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. | |
| </p> | |
| </div> | |
| ) | |
| } | |
| function YourBill({ | |
| open, | |
| stateCode, | |
| city, | |
| county, | |
| jurisdictionName, | |
| onContinue, | |
| }: { | |
| open: boolean | |
| stateCode: string | |
| city?: string | |
| county?: string | |
| jurisdictionName: string | |
| onContinue: () => void | |
| }) { | |
| const propQ = useQuery<PropertyTaxRate>({ | |
| 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<SalesTaxRate>({ | |
| 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<number | null>(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 ( | |
| <div> | |
| <div className="flex flex-col gap-3 md:flex-row md:items-start md:gap-5"> | |
| {/* Inputs */} | |
| <div className="flex-1"> | |
| <div className="mb-3 inline-flex rounded-xl border border-[#d4e8e8] bg-[#f7fafb] p-0.5 sm:mb-4"> | |
| {[ | |
| { v: true, label: 'I own' }, | |
| { v: false, label: 'I rent' }, | |
| ].map((o) => ( | |
| <button | |
| key={o.label} | |
| type="button" | |
| onClick={() => setOwn(o.v)} | |
| className={`rounded-lg px-3 py-1 text-[12px] font-semibold transition-colors sm:px-4 sm:py-1.5 sm:text-[13px] ${ | |
| own === o.v ? 'bg-[#1a6b6b] text-white' : 'text-[#56635e] hover:text-[#0f2b2b]' | |
| }`} | |
| style={FONT} | |
| > | |
| {o.label} | |
| </button> | |
| ))} | |
| </div> | |
| {own ? ( | |
| <EstRow label="Home value" value={hv} min={50_000} max={homeMax} step={5_000} onChange={setHomeValue} display={fmtDollars(hv)} log /> | |
| ) : ( | |
| <p className="mb-3 rounded-lg bg-[#f7fafb] px-3 py-2 text-[12px] leading-relaxed text-[#6b8a8a]" style={FONT}> | |
| 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. | |
| </p> | |
| )} | |
| <EstRow label="Your yearly spending on taxable goods" value={spend} min={0} max={100_000} step={500} onChange={setSpend} display={spend > 0 ? fmtDollars(spend) : 'add yours'} /> | |
| <EstRow label="Your local fees (utilities, permits, garbage)" value={fees} min={0} max={2_500} step={50} onChange={setFees} display={fees > 0 ? fmtDollars(fees) : 'add yours'} /> | |
| <EstRow label="Your household income" value={income} min={0} max={250_000} step={1_000} onChange={setIncome} display={income > 0 ? fmtDollars(income) : 'add yours'} /> | |
| </div> | |
| {/* Live result */} | |
| <div className="flex-1 rounded-2xl border border-[#d4e8e8] bg-[#fafaf9] p-3 sm:p-5"> | |
| <div className="flex items-center gap-4"> | |
| <div className="flex-1"> | |
| <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#5d7d7d]" style={MONO}> | |
| You pay approximately | |
| </p> | |
| <p className="text-[36px] font-semibold leading-none text-[#0f2b2b]" style={SERIF}> | |
| {total > 0 ? fmtDollars(total) : 'β'} | |
| </p> | |
| {total > 0 && ( | |
| <p className="mt-1 text-[13px] text-[#56635e]" style={FONT}> | |
| per year Β· {fmtDollars(total / 12)}/mo | |
| </p> | |
| )} | |
| {(acsYear || salesYear) && ( | |
| <p className="mt-1 text-[10.5px] uppercase tracking-[0.08em] text-[#5d7d7d]" style={MONO}> | |
| {acsYear ? `Property ${acsYear} ACS` : ''} | |
| {acsYear && salesYear ? ' Β· ' : ''} | |
| {salesYear ? `Sales ${salesYear}` : ''} | |
| </p> | |
| )} | |
| </div> | |
| <BillDonut parts={parts} /> | |
| </div> | |
| {sharePct != null && ( | |
| <p className="mt-3 text-[13.5px] font-semibold text-[#0f2b2b]" style={FONT}> | |
| That's {sharePct.toFixed(1)}% of your household income going to local government. | |
| </p> | |
| )} | |
| <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1"> | |
| {parts.map((p) => ( | |
| <span key={p.label} className="flex items-center gap-1.5 text-[11.5px] text-[#56635e]" style={FONT}> | |
| <span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} /> | |
| {p.label} Β· {fmtDollars(p.value)} | |
| </span> | |
| ))} | |
| </div> | |
| {(spend === 0 || income === 0) && ( | |
| <p className="mt-3 rounded-lg bg-[#f0faf8] px-3 py-2 text-[12px] leading-relaxed text-[#2a5a52]" style={FONT}> | |
| 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. | |
| </p> | |
| )} | |
| <p className="mt-3 text-[11px] leading-relaxed text-[#5d7d7d]" style={FONT}> | |
| {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. | |
| </p> | |
| </div> | |
| </div> | |
| {/* Who collects it β REAL per-resident tax collections by level. */} | |
| <WhoCollectsBar open={open} stateCode={stateCode} city={city} county={county} /> | |
| <button | |
| type="button" | |
| onClick={onContinue} | |
| className="mgm-pulse mt-5 w-full rounded-xl bg-[#1a6b6b] px-5 py-3.5 text-[15px] font-semibold text-white transition-colors hover:bg-[#155757]" | |
| style={FONT} | |
| > | |
| Now β can you guess where it goes? β | |
| </button> | |
| </div> | |
| ) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 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 ( | |
| <div | |
| className="mgm-ring flex h-24 w-24 shrink-0 items-center justify-center rounded-full border-[3px] border-dashed border-[#cfe0e0]" | |
| aria-hidden | |
| > | |
| <span className="text-[30px] font-semibold text-[#cfe0e0]" style={SERIF}> | |
| ? | |
| </span> | |
| </div> | |
| ) | |
| } | |
| 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 ( | |
| <div | |
| className="relative h-24 w-24 shrink-0 rounded-full" | |
| style={{ background: `conic-gradient(${stops})` }} | |
| aria-hidden | |
| > | |
| <div className="absolute inset-[30%] flex items-center justify-center rounded-full bg-white text-center"> | |
| <span | |
| className="text-[9px] font-semibold uppercase leading-tight tracking-[0.08em] text-[#5d7d7d]" | |
| style={MONO} | |
| > | |
| Your | |
| <br /> | |
| guess | |
| </span> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // 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 ( | |
| <div | |
| className="relative h-[76px] w-[76px] shrink-0 rounded-full" | |
| style={{ background: `conic-gradient(${stops})` }} | |
| aria-hidden | |
| > | |
| <div className="absolute inset-[28%] flex items-center justify-center rounded-full bg-white text-center"> | |
| <span | |
| className={`text-[8.5px] font-semibold uppercase leading-tight tracking-[0.06em] ${accent ? 'text-[#1a6b6b]' : 'text-[#5d7d7d]'}`} | |
| style={MONO} | |
| > | |
| {label} | |
| </span> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 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<SpendingLevel, 'combined'>): 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<Record<SpendingLevel, boolean>> | |
| }) { | |
| return ( | |
| <div | |
| role="tablist" | |
| aria-label="Government level" | |
| className="inline-flex max-w-full flex-wrap gap-0.5 rounded-xl border border-[#d4e8e8] bg-[#f7fafb] p-0.5 sm:gap-1" | |
| > | |
| {SPENDING_LEVELS.map((opt) => { | |
| const active = value === opt.value | |
| const disabled = !!disabledLevels[opt.value] | |
| return ( | |
| <button | |
| key={opt.value} | |
| type="button" | |
| role="tab" | |
| aria-selected={active} | |
| disabled={disabled} | |
| onClick={() => !disabled && onChange(opt.value)} | |
| className={`rounded-lg px-2 py-1 text-[11px] font-semibold transition-colors sm:px-3.5 sm:py-1.5 sm:text-[12.5px] ${ | |
| active | |
| ? 'bg-[#1a6b6b] text-white' | |
| : disabled | |
| ? 'cursor-not-allowed text-[#cfe0e0]' | |
| : 'text-[#56635e] hover:text-[#0f2b2b]' | |
| }`} | |
| style={FONT} | |
| title={disabled ? `No ${levelNoun(opt.value as Exclude<SpendingLevel, 'combined'>)} data for this location` : undefined} | |
| > | |
| {opt.label} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| } | |
| // 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 ( | |
| <div | |
| className="relative h-[120px] w-[120px] shrink-0 rounded-full" | |
| style={{ background: `conic-gradient(${stops})` }} | |
| aria-hidden | |
| > | |
| <div className="absolute inset-[28%] rounded-full bg-white" /> | |
| </div> | |
| ) | |
| } | |
| // 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<SpendingLevel, 'combined'> | |
| 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<SpendingLevel, 'combined'>) => void | |
| }) { | |
| const { data, isLoading, isError, error } = useQuery<LocalFinance>({ | |
| 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 ( | |
| <div className="rounded-2xl border border-[#d4e8e8] bg-white p-5" aria-hidden> | |
| <div className="h-4 w-1/3 animate-pulse rounded bg-[#eef4f4]" /> | |
| <div className="mt-4 space-y-3"> | |
| {[0, 1, 2, 3].map((r) => ( | |
| <div key={r} className="h-3 animate-pulse rounded-full bg-[#eef4f4]" /> | |
| ))} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| const noData = is404 || !data || data.categories.filter((c) => c.amount > 0).length === 0 | |
| if (isError && !is404) { | |
| return ( | |
| <div className="rounded-2xl border border-dashed border-[#d4e8e8] bg-white p-8 text-center text-sm text-[#6b8a8a]" style={FONT}> | |
| We couldn't load {levelNoun(level)} spending right now. Please try again in a moment. | |
| </div> | |
| ) | |
| } | |
| if (noData) { | |
| return ( | |
| <div className="rounded-2xl border border-dashed border-[#d4e8e8] bg-white p-8 text-center" style={FONT}> | |
| <p className="text-sm font-semibold text-[#0f2b2b]"> | |
| No {levelNoun(level)} spending data for this location. | |
| </p> | |
| <p className="mt-1.5 text-[12.5px] leading-relaxed text-[#5d7d7d]"> | |
| 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. | |
| </p> | |
| </div> | |
| ) | |
| } | |
| // 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 ( | |
| <div className="rounded-2xl border border-[#d4e8e8] bg-white p-4 shadow-[0_4px_20px_rgba(26,107,107,0.06)] sm:p-5"> | |
| <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| <h3 className="text-[15px] font-semibold text-[#0f2b2b]" style={SERIF}> | |
| {data.jurisdiction_name} β where it goes | |
| </h3> | |
| <span className="text-[11px] uppercase tracking-[0.06em] text-[#5d7d7d]" style={MONO}> | |
| FY {data.fiscal_year} | |
| </span> | |
| </div> | |
| {/* Statewide-fallback honesty note (matched===false). */} | |
| {!data.matched && ( | |
| <p className="mt-2 rounded-lg bg-[#f7fafb] px-3 py-2 text-[12px] leading-relaxed text-[#6b8a8a]" style={FONT}> | |
| No exact {levelNoun(level)} match for this location β showing the statewide {levelNoun(level)} figures. | |
| </p> | |
| )} | |
| <div className="mt-4 flex flex-wrap items-center gap-5"> | |
| <SpendingDonut categories={cats} /> | |
| <div className="min-w-[220px] flex-1 space-y-2"> | |
| {data.direct_expenditure != null && data.direct_expenditure > 0 && ( | |
| <div className="mb-1 flex items-baseline justify-between border-b border-[#eef4f4] pb-2"> | |
| <span className="text-[12px] font-semibold uppercase tracking-[0.06em] text-[#5d7d7d]" style={MONO}> | |
| Total direct expenditure | |
| </span> | |
| <span className="text-[15px] font-semibold tabular-nums text-[#0f2b2b]" style={FONT}> | |
| {fmtDollars(data.direct_expenditure)} | |
| </span> | |
| </div> | |
| )} | |
| {cats.map((c, i) => ( | |
| <div key={c.category} className="flex items-baseline justify-between text-[13px]" style={FONT}> | |
| <span className="flex min-w-0 items-center gap-1.5 font-medium text-[#0f2b2b]"> | |
| <span | |
| className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" | |
| style={{ backgroundColor: CAT_PALETTE[i % CAT_PALETTE.length] }} | |
| /> | |
| <span className="truncate">{c.category}</span> | |
| </span> | |
| <span className="shrink-0 tabular-nums"> | |
| {c.share_pct != null && ( | |
| <span className="font-semibold text-[#1a6b6b]">{pct(c.share_pct)}</span> | |
| )} | |
| <span className="ml-2 text-[#56635e]">{fmtDollars(c.amount)}</span> | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| <p className="mt-4 border-t border-[#eef4f4] pt-2 text-[11px] leading-relaxed text-[#5d7d7d]" style={FONT}> | |
| {data.source} | |
| {data.note ? ` Β· ${data.note}` : ''} | |
| </p> | |
| </div> | |
| ) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 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 ( | |
| <div className="rounded-2xl border border-[#d4e8e8] bg-white p-3.5 shadow-[0_4px_20px_rgba(26,107,107,0.06)] sm:p-4"> | |
| <div className="flex items-baseline justify-between gap-3"> | |
| <h3 className="text-[15px] font-semibold text-[#0f2b2b]" style={SERIF}> | |
| The guessing game | |
| </h3> | |
| {hintDismissed && ( | |
| <span className="text-[11px] text-[#5d7d7d]" style={FONT}> | |
| Slide your gut feeling, then reveal. | |
| </span> | |
| )} | |
| </div> | |
| {/* Dismissible scoring explainer β compact one-liner so the sliders stay above the fold. */} | |
| {!hintDismissed && ( | |
| <div className="mt-1.5 flex items-start gap-2 rounded-lg border border-[#cdece7] bg-[#f0faf8] px-2.5 py-1.5"> | |
| <p className="flex-1 text-[11.5px] leading-snug text-[#2a5a52]" style={FONT}> | |
| <span className="font-semibold">How scoring works:</span> drag all {game.length} to your gut | |
| feeling β they auto-balance to 100%. On reveal you score against the real adopted budget. | |
| </p> | |
| <button | |
| type="button" | |
| onClick={() => setHintDismissed(true)} | |
| aria-label="Dismiss" | |
| className="-mr-0.5 shrink-0 rounded text-[13px] leading-none text-[#1a6b6b] transition-colors hover:text-[#0f2b2b]" | |
| > | |
| β | |
| </button> | |
| </div> | |
| )} | |
| {/* Guessing: donut + sliders (hidden once revealed). */} | |
| {!revealed && ( | |
| <> | |
| <div className="mx-auto mt-3 flex max-w-[640px] flex-wrap items-center justify-center gap-x-5 gap-y-3"> | |
| <GuessDonut game={game} guesses={guesses} touched={touched} /> | |
| <div className="min-w-[200px] max-w-[440px] flex-1 space-y-2"> | |
| {game.map((c, i) => { | |
| const color = CAT_PALETTE[i % CAT_PALETTE.length] | |
| const fill = touched[i] ? Math.round(guesses[i]) : 0 | |
| return ( | |
| <div key={c.category}> | |
| <div className="mb-1 flex items-center justify-between text-[13px]" style={FONT}> | |
| <span className="flex items-center gap-1.5 font-medium text-[#0f2b2b]"> | |
| <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} /> | |
| {c.category} | |
| {i === firstUntouched && ( | |
| <span className="mgm-nudge text-[11px] font-semibold text-[#1a6b6b]"> | |
| drag to guess β | |
| </span> | |
| )} | |
| </span> | |
| <span className="tabular-nums"> | |
| {touched[i] ? ( | |
| <span className="font-semibold text-[#1a6b6b]">{pct(normGuess(i))}</span> | |
| ) : ( | |
| <span className="font-semibold text-[#cfe0e0]">?</span> | |
| )} | |
| </span> | |
| </div> | |
| <input | |
| type="range" | |
| min={0} | |
| max={100} | |
| step={1} | |
| value={Math.round(guesses[i])} | |
| onChange={(e) => 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 && ( | |
| <div className="mt-2"> | |
| <button | |
| type="button" | |
| onClick={() => setOtherExpanded((v) => !v)} | |
| aria-expanded={otherExpanded} | |
| className="flex items-center gap-1 text-[11px] font-medium text-[#1a6b6b] transition-colors hover:text-[#0f2b2b]" | |
| style={FONT} | |
| > | |
| <span className="inline-block transition-transform" style={{ transform: otherExpanded ? 'rotate(90deg)' : 'none' }} aria-hidden> | |
| βΈ | |
| </span> | |
| {otherExpanded ? 'Hide whatβs inside' : 'Whatβs in βOtherβ?'} | |
| </button> | |
| {otherExpanded && ( | |
| <ul className="mt-1.5 space-y-1 rounded-lg border border-[#eef4f4] bg-[#f7fafb] px-3 py-2"> | |
| {c.breakdown.map((part) => ( | |
| <li key={part.label} className="flex items-center justify-between text-[11px]" style={FONT}> | |
| <span className="text-[#56635e]">{part.label}</span> | |
| <span className="text-[#5d7d7d]">included</span> | |
| </li> | |
| ))} | |
| </ul> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => allGuessed && onReveal()} | |
| disabled={!allGuessed} | |
| className={`mt-4 w-full rounded-xl px-5 py-3 text-[15px] font-semibold transition-colors ${ | |
| allGuessed ? 'mgm-pulse bg-[#1a6b6b] text-white hover:bg-[#155757]' : 'cursor-default bg-[#eef4f4] text-[#5d7d7d]' | |
| }`} | |
| style={FONT} | |
| > | |
| {allGuessed ? 'Reveal reality' : `Guess all ${game.length} to reveal Β· ${game.length - touchedCount} to go`} | |
| </button> | |
| </> | |
| )} | |
| {/* Reveal: your guess vs the real budget β two donuts + grow-in bars with | |
| a vertical guess marker. */} | |
| {revealed && ( | |
| <div className="mt-4 mgm-fade"> | |
| <div className="flex flex-wrap items-center gap-5"> | |
| <div className="flex shrink-0 gap-3"> | |
| <RevealDonut | |
| parts={game.map((_, i) => ({ pct: guesses[i], color: CAT_PALETTE[i % CAT_PALETTE.length] }))} | |
| label={<>YOUR<br />GUESS</>} | |
| /> | |
| <RevealDonut | |
| parts={game.map((c, i) => ({ pct: c.actual, color: CAT_PALETTE[i % CAT_PALETTE.length] }))} | |
| label="REAL" | |
| accent | |
| /> | |
| </div> | |
| <div className="min-w-[240px] flex-1 space-y-2.5"> | |
| {game.map((c, i) => { | |
| const g = normGuess(i) | |
| const d = Math.round(g - c.actual) | |
| const color = CAT_PALETTE[i % CAT_PALETTE.length] | |
| return ( | |
| <div key={c.category}> | |
| <div className="flex items-baseline justify-between text-[12.5px]" style={FONT}> | |
| <span className="flex items-center gap-1.5 font-medium text-[#0f2b2b]"> | |
| <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} /> | |
| {c.category} | |
| </span> | |
| <span> | |
| <span className="font-semibold tabular-nums text-[#0f2b2b]">{pct(c.actual)}</span> | |
| {c.amount != null && ( | |
| <span className="ml-1.5 tabular-nums text-[11.5px] text-[#56635e]" title={fmtDollars(c.amount)}> | |
| {fmtDollarsCompact(c.amount)}/yr | |
| </span> | |
| )} | |
| <span className="ml-1.5 text-[11px]" style={{ color: Math.abs(d) >= 8 ? '#9a6b12' : '#5d7d7d' }}> | |
| {Math.abs(d) <= 3 ? 'β close' : d > 0 ? `${Math.abs(d)} high` : `${Math.abs(d)} low`} | |
| </span> | |
| </span> | |
| </div> | |
| <div className="relative mt-1 h-2.5 rounded-full bg-[#f1f5f5]"> | |
| <div className="mgm-grow h-full rounded-full" style={{ width: `${Math.min(100, c.actual)}%`, backgroundColor: color }} /> | |
| <div | |
| className="absolute -top-[3px] h-[15px] w-[2.5px] rounded bg-[#0f2b2b]" | |
| style={{ left: `calc(${Math.min(100, g)}% - 1px)` }} | |
| title={`Your guess: ${pct(g)}`} | |
| /> | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| <p className="text-[9.5px] uppercase tracking-[0.05em] text-[#5d7d7d]" style={MONO}> | |
| Bar = real budget Β· | = your guess | |
| </p> | |
| </div> | |
| </div> | |
| {top && ( | |
| <p className="mt-3.5 text-center text-[13px] leading-relaxed text-[#6b8a8a]" style={FONT}> | |
| <span className="font-semibold text-[#1a6b6b]">{pct(top.actual)}</span> of every local dollar | |
| goes to {top.category} alone β every year. | |
| </p> | |
| )} | |
| {/* These are REAL combined Census figures β city + county + school | |
| district stacked. That's why Education is a big slice now. */} | |
| <p className="mt-2 rounded-lg bg-[#f7fafb] px-3 py-2 text-center text-[11.5px] leading-relaxed text-[#6b8a8a]" style={FONT}> | |
| 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. | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // 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 ( | |
| <div> | |
| <div | |
| className="mb-2 whitespace-pre-line text-[10px] font-semibold uppercase leading-tight tracking-[0.08em] text-[#5d7d7d]" | |
| style={MONO} | |
| > | |
| {title} | |
| </div> | |
| {options.map(([value, label]) => { | |
| const isActive = active === value | |
| return ( | |
| <button | |
| key={value} | |
| type="button" | |
| onClick={() => onSelect(value)} | |
| className="flex items-center gap-2 py-1" | |
| > | |
| <span | |
| className="box-border h-[11px] w-[11px] shrink-0 rounded-full transition-colors" | |
| style={{ | |
| background: isActive ? '#1a6b6b' : '#fff', | |
| border: isActive ? '2px solid #1a6b6b' : '1.5px solid #a8a29e', | |
| }} | |
| /> | |
| <span | |
| className="text-[10.5px] font-semibold uppercase tracking-[0.05em] transition-colors" | |
| style={{ ...MONO, color: isActive ? '#1a6b6b' : '#44403c' }} | |
| > | |
| {label} | |
| </span> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| ) | |
| } | |
| // 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 ( | |
| <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label="Child income rank (percentile, 0β100): your area vs the U.S."> | |
| {/* Y-axis unit so the bare 25/50/75 ticks read as a rank, not dollars. */} | |
| <text x="52" y={topY - 6} fontSize="7" fill="#9bb8b8" textAnchor="end" fontFamily="'IBM Plex Mono', monospace">PCTILE</text> | |
| {[25, 50, 75].map((g) => ( | |
| <g key={g}> | |
| <line x1="58" x2="250" y1={y(g)} y2={y(g)} stroke="#f1f5f5" strokeWidth="1" /> | |
| <text x="52" y={y(g) + 3} fontSize="8.5" fill="#9bb8b8" textAnchor="end" fontFamily="'IBM Plex Mono', monospace">{g}</text> | |
| </g> | |
| ))} | |
| {localPct != null ? ( | |
| <> | |
| <line x1={xUS} y1={y(natPct)} x2={xLoc} y2={y(localPct)} stroke="#1a6b6b" strokeWidth="2.5" strokeLinecap="round" /> | |
| <circle cx={xUS} cy={y(natPct)} r="4.5" fill="#9bb8b8" /> | |
| <circle cx={xLoc} cy={y(localPct)} r="5" fill="#1a6b6b" /> | |
| <text x={xLoc + 9} y={y(localPct) + 1} fontSize="11" fontWeight="800" fill="#1a6b6b" fontFamily="'Source Sans 3', sans-serif"> | |
| {ordinal(localPct)} Β· {short} | |
| </text> | |
| <text x={xUS - 9} y={y(natPct) - 6} fontSize="10" fontWeight="700" fill="#9bb8b8" textAnchor="end" fontFamily="'Source Sans 3', sans-serif"> | |
| {ordinal(natPct)} Β· U.S. | |
| </text> | |
| </> | |
| ) : ( | |
| <> | |
| <line x1="58" x2="250" y1={y(natPct)} y2={y(natPct)} stroke="#9bb8b8" strokeWidth="2.5" strokeDasharray="4 3" /> | |
| <text x="250" y={y(natPct) - 6} fontSize="10.5" fontWeight="700" fill="#9bb8b8" textAnchor="end" fontFamily="'Source Sans 3', sans-serif"> | |
| U.S. avg {ordinal(natPct)} | |
| </text> | |
| </> | |
| )} | |
| <text x={xUS} y={H - 4} fontSize="9" fill="#78716c" textAnchor="middle" fontFamily="'IBM Plex Mono', monospace">U.S. AVG</text> | |
| <text x={xLoc} y={H - 4} fontSize="9" fill="#1c1917" fontWeight="700" textAnchor="middle" fontFamily="'IBM Plex Mono', monospace">{short.toUpperCase()}</text> | |
| </svg> | |
| ) | |
| } | |
| 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<GrandkidOutlookData>({ | |
| 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 ( | |
| <div className="overflow-hidden rounded-2xl border border-[#d4e8e8] bg-white shadow-[0_4px_20px_rgba(26,107,107,0.06)]"> | |
| {/* Teal verdict header (real numbers only). */} | |
| <div className="bg-[#1a6b6b] px-5 py-3.5 text-white"> | |
| <h3 className="text-[15.5px] font-semibold leading-snug" style={SERIF}> | |
| {verdict} | |
| </h3> | |
| <p className="mt-0.5 text-[12px] text-white/85" style={FONT}> | |
| Adult income rank for kids with <span className="font-semibold">{parentIncome}-income</span> parents | |
| {demoQual ? ` Β· ${demoQual}` : ''} Β· vs the U.S. | |
| </p> | |
| </div> | |
| <div className="flex flex-wrap items-start gap-5 p-5"> | |
| {/* Dot-column selectors β REAL Atlas dimensions. */} | |
| <div className="flex shrink-0 gap-5"> | |
| <DotColumn title={'Parent\nincome'} options={PARENT_OPTIONS} active={parentIncome} onSelect={setParentIncome} /> | |
| <DotColumn title={'Child\nrace'} options={RACE_OPTIONS} active={race} onSelect={setRace} /> | |
| <DotColumn title={'Child\ngender'} options={GENDER_OPTIONS} active={gender} onSelect={setGender} /> | |
| </div> | |
| {/* Chart β dashed "?" while blank, else the local-vs-U.S. slope. */} | |
| <div className="min-w-[220px] flex-1"> | |
| {blank ? ( | |
| <div className="flex h-[134px] items-center justify-center"> | |
| <div className="mgm-ring flex h-24 w-24 items-center justify-center rounded-full border-[3px] border-dashed border-[#cfe0e0]" aria-hidden> | |
| <span className="text-[30px] font-semibold text-[#cfe0e0]" style={SERIF}>?</span> | |
| </div> | |
| </div> | |
| ) : ( | |
| <> | |
| <SlopeChart localPct={hasLocal ? (localPct as number) : null} natPct={natPct as number} localLabel={localLabel} /> | |
| <p className="mt-1 text-[9.5px] uppercase tracking-[0.08em] text-[#5d7d7d]" style={MONO}> | |
| 0 = bottom Β· 100 = top of the U.S. income ladder | |
| </p> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {/* Notes β honest about missing local cells + provenance. */} | |
| <div className="px-5 pb-4"> | |
| {isError && ( | |
| <p className="text-[12px] text-[#5d7d7d]" style={FONT}> | |
| We couldn't load mobility data right now. Try again, or change a filter. | |
| </p> | |
| )} | |
| {!blank && !hasLocal && ( | |
| <p className="rounded-lg bg-[#f7fafb] px-3 py-2 text-[12px] leading-relaxed text-[#6b8a8a]" style={FONT}> | |
| {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.`} | |
| </p> | |
| )} | |
| {!blank && data?.note && ( | |
| <p className="mt-2 text-[12px] leading-relaxed text-[#6b8a8a]" style={FONT}> | |
| {data.note} | |
| </p> | |
| )} | |
| {!blank && data?.source && ( | |
| <p className="mt-2 border-t border-[#eef4f4] pt-2 text-[11px] text-[#5d7d7d]" style={FONT}> | |
| Source:{' '} | |
| {data.source_url ? ( | |
| <a href={data.source_url} target="_blank" rel="noopener noreferrer" className="underline decoration-[#d4e8e8] underline-offset-2 hover:text-[#1a6b6b]"> | |
| {data.source} | |
| </a> | |
| ) : ( | |
| data.source | |
| )} | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Loading skeleton. | |
| // --------------------------------------------------------------------------- | |
| function ModalSkeleton() { | |
| return ( | |
| <div className="grid gap-5 md:grid-cols-2" aria-hidden> | |
| {[0, 1].map((col) => ( | |
| <div key={col} className="space-y-5"> | |
| {[0, 1].map((card) => ( | |
| <div key={card} className="rounded-2xl border border-[#d4e8e8] bg-white p-5"> | |
| <div className="h-4 w-1/3 animate-pulse rounded bg-[#eef4f4]" /> | |
| <div className="mt-4 space-y-3"> | |
| {[0, 1, 2, 3].map((r) => ( | |
| <div key={r} className="h-3 animate-pulse rounded-full bg-[#eef4f4]" /> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| // =========================================================================== | |
| // 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<GateMode>('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<string | null>(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 ( | |
| <div className="rounded-2xl border border-[#d4e8e8] bg-white px-5 py-8 text-center sm:py-10"> | |
| <h3 className="text-[22px] font-semibold text-[#0f2b2b]" style={SERIF}> | |
| First β where's home? | |
| </h3> | |
| <p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[#6b8a8a]" style={FONT}> | |
| {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.'} | |
| </p> | |
| <div className="mt-5 flex flex-wrap items-center justify-center gap-2.5"> | |
| {mode === 'zip' ? ( | |
| <input | |
| value={zip} | |
| onChange={(e) => { | |
| 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' }} | |
| /> | |
| ) : ( | |
| <input | |
| value={text} | |
| onChange={(e) => { | |
| 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' }} | |
| /> | |
| )} | |
| <button | |
| type="button" | |
| onClick={handleShow} | |
| disabled={busy || needsChoice || !inputValid} | |
| className="rounded-full px-6 py-3 text-[15px] font-semibold text-white transition-colors" | |
| style={{ | |
| ...FONT, | |
| backgroundColor: inputValid && !needsChoice ? '#1a6b6b' : '#cfe0e0', | |
| cursor: inputValid && !needsChoice && !busy ? 'pointer' : 'default', | |
| }} | |
| > | |
| {busy ? 'Findingβ¦' : needsChoice ? 'Pick your area' : 'Show me my money'} | |
| </button> | |
| </div> | |
| {/* Real geography: this ZIP spans places / city limits, and city rates stack | |
| on the county's, so the choice changes the bill. */} | |
| {needsChoice && choices && ( | |
| <div className="mt-4"> | |
| <p className="text-[13px] font-semibold text-[#56635e]" style={FONT}> | |
| {mode === 'zip' | |
| ? `${zip} crosses jurisdiction lines β where's home? (City taxes stack on the county's.)` | |
| : 'We found a few matches β which is home?'} | |
| </p> | |
| <div className="mt-2 flex flex-wrap justify-center gap-2"> | |
| {choices.map((c, i) => ( | |
| <button | |
| key={c.label + i} | |
| type="button" | |
| onClick={() => onResolved(c.loc)} | |
| className="rounded-full border border-[#d4e8e8] bg-white px-4 py-2 text-[13.5px] font-semibold text-[#0f2b2b] transition-colors hover:border-[#1a6b6b]" | |
| style={FONT} | |
| > | |
| {c.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {error && ( | |
| <p className="mt-3 text-[12.5px] font-semibold text-[#b45309]" style={FONT}> | |
| {error} | |
| </p> | |
| )} | |
| <button | |
| type="button" | |
| onClick={useMyLocation} | |
| className="mt-4 block w-full text-[13px] font-semibold text-[#0f766e] transition-colors hover:underline" | |
| style={FONT} | |
| > | |
| {locating ? 'Locatingβ¦' : 'π or use my location'} | |
| </button> | |
| {/* Other ways to find home β city/county or full address, both resolved by | |
| the same real geocoder. */} | |
| <div className="mt-3 flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5"> | |
| {mode !== 'zip' && ( | |
| <button | |
| type="button" | |
| onClick={() => switchMode('zip')} | |
| className="text-[12.5px] font-semibold text-[#0f766e] transition-colors hover:underline" | |
| style={FONT} | |
| > | |
| β Use ZIP code instead | |
| </button> | |
| )} | |
| {mode !== 'place' && ( | |
| <button | |
| type="button" | |
| onClick={() => switchMode('place')} | |
| className="text-[12.5px] font-semibold text-[#0f766e] transition-colors hover:underline" | |
| style={FONT} | |
| > | |
| Don't know your ZIP? Search by city or county | |
| </button> | |
| )} | |
| {mode !== 'address' && ( | |
| <button | |
| type="button" | |
| onClick={() => switchMode('address')} | |
| className="text-[12.5px] font-semibold text-[#0f766e] transition-colors hover:underline" | |
| style={FONT} | |
| > | |
| Search by full address instead | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| 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<LocationData | null>(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<CombinedFinance>({ | |
| 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<number[]>([]) | |
| const [touched, setTouched] = useState<boolean[]>([]) | |
| 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<SpendingLevel>('combined') | |
| // Levels we've learned have no data (404) β greyed out in the selector. | |
| const [disabledLevels, setDisabledLevels] = useState<Partial<Record<SpendingLevel, boolean>>>({}) | |
| const markLevelNoData = useCallback((l: Exclude<SpendingLevel, 'combined'>) => { | |
| 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 = ( | |
| <div> | |
| <GrandkidsForecast open={open} stateCode={scopeState as string} city={scopeCity} /> | |
| <button | |
| type="button" | |
| onClick={() => setStage('game')} | |
| className="mt-3 text-[13px] text-[#6b8a8a] underline transition-colors hover:text-[#0f2b2b]" | |
| style={FONT} | |
| > | |
| β Back to the game | |
| </button> | |
| </div> | |
| ) | |
| return ( | |
| <Transition appear show={open} as={Fragment}> | |
| <Dialog as="div" className="relative z-[60]" onClose={onClose}> | |
| <Transition.Child | |
| as={Fragment} | |
| enter="ease-out duration-200" | |
| enterFrom="opacity-0" | |
| enterTo="opacity-100" | |
| leave="ease-in duration-150" | |
| leaveFrom="opacity-100" | |
| leaveTo="opacity-0" | |
| > | |
| <div className="fixed inset-0 bg-[#0f2b2b]/50 backdrop-blur-sm" aria-hidden="true" /> | |
| </Transition.Child> | |
| <div className="fixed inset-0 overflow-y-auto"> | |
| <div className="flex min-h-full items-center justify-center p-3 sm:p-6"> | |
| <Transition.Child | |
| as={Fragment} | |
| enter="ease-out duration-200" | |
| enterFrom="opacity-0 scale-95" | |
| enterTo="opacity-100 scale-100" | |
| leave="ease-in duration-150" | |
| leaveFrom="opacity-100 scale-100" | |
| leaveTo="opacity-0 scale-95" | |
| > | |
| <Dialog.Panel className="relative flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-3xl bg-[#f7fafb] text-left shadow-2xl"> | |
| {/* Close button β pinned to the panel, outside the scroll area. */} | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| className="absolute right-3 top-3 z-20 rounded-full bg-white/90 p-1.5 text-[#56635e] shadow-sm backdrop-blur transition-colors hover:bg-white hover:text-[#0f2b2b] sm:right-4 sm:top-4" | |
| aria-label="Close" | |
| > | |
| <XMarkIcon className="h-5 w-5" /> | |
| </button> | |
| {/* Header β title, subtitle, stacked-governments, step tabs. | |
| Stays put; only the tab body below scrolls. */} | |
| <div className="shrink-0 px-4 pt-3 sm:px-7 sm:pt-5"> | |
| <Dialog.Title | |
| className="pr-10 text-[20px] font-semibold leading-tight text-[#0f2b2b] sm:text-[24px]" | |
| style={SERIF} | |
| > | |
| {title} | |
| </Dialog.Title> | |
| {/* Subtitle β prototype-style, all real data. */} | |
| <p className="mt-0.5 text-[12px] leading-snug text-[#6b8a8a] sm:text-[13px]" style={FONT}> | |
| {hasPlace ? ( | |
| <> | |
| {placeName || data?.jurisdiction_name} | |
| {data?.state_code ? `, ${data.state_code}` : ''}{' '} | |
| <button | |
| type="button" | |
| onClick={changePlace} | |
| className="font-semibold text-[#0f766e] underline-offset-2 hover:underline" | |
| style={FONT} | |
| > | |
| (change) | |
| </button>{' '} | |
| Β· starts at the median household β adjust the sliders to make it yours. | |
| </> | |
| ) : ( | |
| <>First, tell us where home is β every town reaches into your pocket a little differently.</> | |
| )} | |
| </p> | |
| {/* Stacked-governments callout β the FULL local government a | |
| resident funds (city + county + their school district), so | |
| K-12 spending is included rather than hidden. */} | |
| {data && data.governments.length > 0 && ( | |
| <p className="mt-1.5 rounded-lg bg-[#f0faf8] px-2.5 py-1 text-[11.5px] leading-snug text-[#2a5a52] sm:mt-2 sm:px-3 sm:py-1.5 sm:text-[12.5px]" style={FONT}> | |
| <span className="font-bold"> | |
| {data.governments.map((g) => labelGovernment(g)).join(' + ')} | |
| </span> | |
| <span className="text-[#3f6f67]"> β stacked, because you fund all of them.</span> | |
| </p> | |
| )} | |
| {/* Step indicator (1 Β· Your bill β 2 Β· The guessing game β 3 Β· The grandkids). | |
| Tab 1 is always available (it hosts the "where's home?" gate); the | |
| others unlock once a place is chosen and its data has loaded. */} | |
| <div className="mt-2 flex flex-nowrap gap-1 overflow-x-auto sm:mt-3 sm:flex-wrap sm:gap-2"> | |
| {STEP_TABS.map(({ key, label, short }) => { | |
| const unlocked = key === 'estimate' ? true : key === 'game' ? !!data : revealed | |
| const active = stage === key | |
| return ( | |
| <button | |
| key={key} | |
| type="button" | |
| onClick={() => unlocked && setStage(key)} | |
| className={`shrink-0 rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.04em] transition-colors sm:px-3 sm:py-1.5 sm:text-[11px] sm:tracking-[0.06em] ${ | |
| active | |
| ? 'border-[#1a6b6b] bg-[#f0faf8] text-[#1a6b6b]' | |
| : unlocked | |
| ? 'border-[#e2eaea] bg-white text-[#6b8a8a] hover:text-[#0f2b2b]' | |
| : 'cursor-default border-[#eef4f4] bg-white text-[#cfe0e0]' | |
| }`} | |
| style={MONO} | |
| > | |
| <span className="sm:hidden">{short}</span> | |
| <span className="hidden sm:inline">{label}</span> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| {/* Scrollable body β only this region scrolls vertically, so the | |
| title and step tabs above stay fixed in view. */} | |
| <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-5 sm:px-7 sm:pb-7"> | |
| <div className="mt-2 sm:mt-3"> | |
| {!hasPlace ? ( | |
| /* ββ Tab 1 gate: resolve a real place before any bill ββ */ | |
| <WhereIsHome onResolved={onResolvePlace} /> | |
| ) : stage === 'grandkids' ? ( | |
| /* Income-mobility forecast β independent of the finance fetch, | |
| so it loads even when this place has no tax-bill data. */ | |
| <> | |
| {grandkidsStage} | |
| <p className="mt-5 text-center text-[11px] text-[#5d7d7d]" style={MONO}> | |
| Sources: U.S. Census Β· ACS property-tax rate Β· Tax Foundation sales tax Β· Opportunity Atlas | |
| </p> | |
| </> | |
| ) : isLoading ? ( | |
| <ModalSkeleton /> | |
| ) : isError || !data ? ( | |
| <div className="rounded-2xl border border-dashed border-[#d4e8e8] bg-white p-10 text-center"> | |
| <p className="text-sm text-[#6b8a8a]" style={FONT}> | |
| We couldn't load finance data right now. Please try again in a moment. | |
| </p> | |
| </div> | |
| ) : ( | |
| <> | |
| {/* ββ Stage 1: Your bill ββ */} | |
| {stage === 'estimate' && ( | |
| <YourBill | |
| open={open} | |
| stateCode={scopeState as string} | |
| city={scopeCity} | |
| county={scopeCounty} | |
| jurisdictionName={data.jurisdiction_name} | |
| onContinue={() => setStage('game')} | |
| /> | |
| )} | |
| {/* ββ Stage 2: The guessing game ββ */} | |
| {stage === 'game' && ( | |
| <div> | |
| {/* Level selector: Combined (the merged donut + game) | |
| vs. a single government's REAL breakdown. */} | |
| <div className="mb-3"> | |
| <div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-[#5d7d7d]" style={MONO}> | |
| Where your tax money goes | |
| </div> | |
| <LevelSelector | |
| value={spendingLevel} | |
| onChange={setSpendingLevel} | |
| disabledLevels={disabledLevels} | |
| /> | |
| </div> | |
| {spendingLevel === 'combined' ? ( | |
| game.length > 0 ? ( | |
| <GuessingGame | |
| placeName={placeName || data.jurisdiction_name} | |
| governments={data.governments} | |
| game={game} | |
| revealed={revealed} | |
| onReveal={() => setRevealed(true)} | |
| guesses={guesses} | |
| setGuesses={setGuesses} | |
| touched={touched} | |
| setTouched={setTouched} | |
| /> | |
| ) : ( | |
| <div className="rounded-2xl border border-dashed border-[#d4e8e8] bg-white p-6 text-center text-sm text-[#6b8a8a]" style={FONT}> | |
| Spending-category breakdown isn't available for{' '} | |
| {data.jurisdiction_name} yet. | |
| </div> | |
| ) | |
| ) : ( | |
| <LevelBreakdown | |
| open={open} | |
| level={spendingLevel} | |
| stateCode={scopeState as string} | |
| city={scopeCity} | |
| county={scopeCounty} | |
| onNoData={markLevelNoData} | |
| /> | |
| )} | |
| {/* Score + grandkids CTA, lands where the reveal | |
| happened β only on the Combined (guessing-game) view. */} | |
| {spendingLevel === 'combined' && revealed && scoreInfo != null && ( | |
| <div className="mgm-fade mt-4 flex flex-wrap items-center gap-4 rounded-2xl border border-[#ccfbf1] bg-gradient-to-r from-[#f0faf8] to-[#f7fee7] px-5 py-4"> | |
| <span className="text-[34px] font-semibold leading-none" style={{ ...SERIF, color: scoreColor(scoreInfo.score) }}> | |
| {pct(scoreInfo.score)} | |
| </span> | |
| <span className="min-w-[120px]"> | |
| <span className="block text-[14.5px] font-semibold text-[#0f2b2b]" style={FONT}> | |
| {gradeFor(scoreInfo.score)} | |
| </span> | |
| <span className="text-[9.5px] uppercase tracking-[0.04em] text-[#5d7d7d]" style={MONO}> | |
| Off by {Math.round(scoreInfo.totalError)} pts | |
| </span> | |
| </span> | |
| <p className="min-w-[220px] flex-1 text-[13px] leading-relaxed text-[#44403c]" style={FONT}> | |
| These dollars shape the schools, streets, and parks your grandkids grow | |
| up with. Want to see how kids who grow up here actually fare? | |
| </p> | |
| <button | |
| type="button" | |
| onClick={() => setStage('grandkids')} | |
| className="mgm-pulse rounded-full bg-[#1a6b6b] px-4 py-2.5 text-[14px] font-semibold text-white transition-colors hover:bg-[#155757]" | |
| style={FONT} | |
| > | |
| The grandkids forecast β | |
| </button> | |
| </div> | |
| )} | |
| <button | |
| type="button" | |
| onClick={() => setStage('estimate')} | |
| className="mt-3 text-[13px] text-[#6b8a8a] underline transition-colors hover:text-[#0f2b2b]" | |
| style={FONT} | |
| > | |
| β Back to your bill | |
| </button> | |
| </div> | |
| )} | |
| {/* Provenance β always shown. */} | |
| <p className="mt-5 text-center text-[11px] text-[#5d7d7d]" style={MONO}> | |
| Sources: U.S. Census Β· ACS property-tax rate Β· Tax Foundation sales tax Β· Opportunity Atlas | |
| </p> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| </Dialog.Panel> | |
| </Transition.Child> | |
| </div> | |
| </div> | |
| </Dialog> | |
| </Transition> | |
| ) | |
| } | |