| import { useState } from 'react' |
| import { ChevronDown, ChevronRight } from 'lucide-react' |
| import { LoadingSpinner, ErrorState } from '../components/LoadingState' |
| import { useCoverageRecommendation } from '../lib/api' |
| import MetricCard from '../components/MetricCard' |
| import { REGION } from '../regionConfig' |
|
|
| function formatUsd(n: number | undefined | null): string { |
| if (n == null) return '—' |
| if (n >= 1_000_000) return '$' + (n / 1_000_000).toFixed(1) + 'M' |
| if (n >= 1_000) return '$' + (n / 1_000).toFixed(0) + 'K' |
| return '$' + n.toLocaleString(undefined, { maximumFractionDigits: 0 }) |
| } |
|
|
| const GENDERS = [ |
| { key: 'all', label: 'All' }, |
| { key: 'women', label: 'Women' }, |
| { key: 'men', label: 'Men' }, |
| ] |
| const SETTLEMENTS = [ |
| { key: 'all', label: 'All zones' }, |
| { key: 'informal', label: 'Informal' }, |
| { key: 'mixed', label: 'Mixed' }, |
| { key: 'formal', label: 'Formal' }, |
| ] |
|
|
| export default function ProgramDesigner() { |
| const [payoutUsd] = useState(10) |
| const [showModel, setShowModel] = useState(false) |
| const [gender, setGender] = useState('all') |
| const [settlement, setSettlement] = useState('all') |
| const [workerPct, setWorkerPct] = useState(15) |
| const [philPct, setPhilPct] = useState(45) |
| const [insPct, setInsPct] = useState(40) |
|
|
| function adjustSplit(changed: 'worker' | 'phil' | 'ins', newVal: number) { |
| const val = Math.max(0, Math.min(100, newVal)) |
| if (changed === 'phil') { |
| const remaining = 100 - val |
| const otherTotal = insPct + workerPct || 1 |
| setPhilPct(val) |
| setInsPct(Math.round((remaining * insPct) / otherTotal)) |
| setWorkerPct(100 - val - Math.round((remaining * insPct) / otherTotal)) |
| } else if (changed === 'ins') { |
| const remaining = 100 - val |
| const otherTotal = philPct + workerPct || 1 |
| setInsPct(val) |
| setPhilPct(Math.round((remaining * philPct) / otherTotal)) |
| setWorkerPct(100 - val - Math.round((remaining * philPct) / otherTotal)) |
| } else { |
| const remaining = 100 - val |
| const otherTotal = philPct + insPct || 1 |
| setWorkerPct(val) |
| setPhilPct(Math.round((remaining * philPct) / otherTotal)) |
| setInsPct(100 - val - Math.round((remaining * philPct) / otherTotal)) |
| } |
| } |
|
|
| const coverage = useCoverageRecommendation(payoutUsd, `${gender}|${settlement}`) |
|
|
| if (coverage.isLoading) { |
| return ( |
| <div className="animate-slide-up"> |
| <div data-tour="program-title" style={{ marginBottom: '20px' }}> |
| <h2 className="page-title">Coverage recommendation</h2> |
| </div> |
| <LoadingSpinner message="Loading coverage data…" /> |
| </div> |
| ) |
| } |
| if (coverage.isError) return <ErrorState onRetry={() => coverage.refetch()} /> |
|
|
| const rec = coverage.data?.recommendation |
| const zones = coverage.data?.zones ?? [] |
|
|
| const annual = rec?.annual_cost_total ?? 0 |
| const annualPerWorker = rec?.annual_cost_per_worker ?? 0 |
| const philUsd = (annual * philPct) / 100 |
| const insUsd = (annual * insPct) / 100 |
| const workerUsd = (annual * workerPct) / 100 |
| const philPerWorker = (annualPerWorker * philPct) / 100 |
| const insPerWorker = (annualPerWorker * insPct) / 100 |
| const workerPerYear = (annualPerWorker * workerPct) / 100 |
|
|
| return ( |
| <div className="animate-slide-up"> |
| {/* Editorial header */} |
| <div data-tour="program-title" style={{ marginBottom: '20px' }}> |
| <h2 className="page-title" style={{ maxWidth: '780px' }}> |
| A heat insurance program for {rec?.total_workers?.toLocaleString() ?? '—'} outdoor workers in {REGION.primaryCity} |
| </h2> |
| <p className="page-caption" style={{ maxWidth: '640px' }}> |
| What the tool recommends based on current heat conditions and your filters. |
| </p> |
| </div> |
| |
| {/* Filter chips */} |
| <div |
| style={{ |
| display: 'flex', |
| alignItems: 'center', |
| gap: '8px', |
| marginBottom: '40px', |
| flexWrap: 'wrap', |
| }} |
| > |
| <span className="eyebrow" style={{ marginRight: '4px' }}>Who</span> |
| {GENDERS.map((g) => ( |
| <button |
| key={g.key} |
| onClick={() => setGender(g.key)} |
| className={`chip ${gender === g.key ? 'active' : ''}`} |
| > |
| {g.label} |
| </button> |
| ))} |
| <span className="eyebrow" style={{ marginLeft: '16px', marginRight: '4px' }}> |
| Where |
| </span> |
| {SETTLEMENTS.map((s) => ( |
| <button |
| key={s.key} |
| onClick={() => setSettlement(s.key)} |
| className={`chip ${settlement === s.key ? 'active' : ''}`} |
| > |
| {s.label} |
| </button> |
| ))} |
| </div> |
| |
| {/* Section 1 — Scope */} |
| <div data-tour="program-scope" style={{ marginBottom: '48px' }}> |
| <div className="section-header">Who's covered</div> |
| <div |
| className="grid grid-cols-1 sm:grid-cols-2 gap-6 sm:gap-8" |
| style={{ |
| borderTop: '1px solid #e8e5e1', |
| paddingTop: '24px', |
| }} |
| > |
| <MetricCard |
| label="Workers enrolled" |
| value={rec?.total_workers?.toLocaleString() ?? '—'} |
| subtitle="outdoor workers in the program" |
| /> |
| <MetricCard |
| label="Neighborhoods covered" |
| value={zones.length} |
| subtitle={`of ${rec?.zones_total ?? '—'} monitored`} |
| /> |
| </div> |
| <p |
| style={{ |
| marginTop: '12px', |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '12px', |
| color: '#8d909e', |
| }} |
| > |
| Filtered by the Who / Where selections above. |
| </p> |
| </div> |
| |
| {/* Section 2 — Cost */} |
| <div data-tour="program-cost" style={{ marginBottom: '48px' }}> |
| <div className="section-header">What it costs</div> |
| <div |
| className="grid grid-cols-1 md:grid-cols-[1.4fr_1fr_1fr] gap-6 md:gap-8" |
| style={{ |
| borderTop: '1px solid #e8e5e1', |
| paddingTop: '28px', |
| alignItems: 'end', |
| }} |
| > |
| {[ |
| { |
| label: 'Annual program cost', |
| value: formatUsd(rec?.annual_cost_total), |
| subtitle: `to protect ${rec?.total_workers?.toLocaleString() ?? '—'} workers`, |
| }, |
| { |
| label: 'Cost per worker', |
| value: `$${rec?.annual_cost_per_worker ?? '—'}`, |
| subtitle: 'per year', |
| }, |
| { |
| label: '3-year total', |
| value: formatUsd((rec?.annual_cost_total ?? 0) * 3), |
| subtitle: 'full pilot horizon', |
| }, |
| ].map((m) => ( |
| <div key={m.label}> |
| <div |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '11px', |
| fontWeight: 500, |
| letterSpacing: '0.08em', |
| textTransform: 'uppercase', |
| color: '#8d909e', |
| }} |
| > |
| {m.label} |
| </div> |
| <div |
| style={{ |
| fontFamily: '"Source Serif 4", Georgia, serif', |
| fontSize: '44px', |
| lineHeight: '48px', |
| fontWeight: 400, |
| color: '#1b1e2d', |
| letterSpacing: '-0.01em', |
| fontVariantNumeric: 'tabular-nums', |
| marginTop: '6px', |
| }} |
| > |
| {m.value} |
| </div> |
| <div |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '12px', |
| color: '#8d909e', |
| marginTop: '6px', |
| }} |
| > |
| {m.subtitle} |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| |
| {/* Section 3 — Funding */} |
| <div className="section-header">How it's funded</div> |
| <p |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '12px', |
| color: '#8d909e', |
| marginTop: '-4px', |
| marginBottom: '16px', |
| maxWidth: '720px', |
| }} |
| > |
| Each slice of the annual program cost above goes to a different payer. |
| </p> |
| <div style={{ marginBottom: '40px', maxWidth: '720px' }}> |
| {/* Philanthropy row */} |
| <div style={{ marginBottom: '20px' }}> |
| <div |
| style={{ |
| display: 'flex', |
| alignItems: 'baseline', |
| justifyContent: 'space-between', |
| marginBottom: '6px', |
| }} |
| > |
| <span |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '13px', |
| fontWeight: 500, |
| color: '#8a3d28', |
| }} |
| > |
| Philanthropy |
| </span> |
| <span |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '13px', |
| color: '#606373', |
| fontVariantNumeric: 'tabular-nums', |
| }} |
| > |
| {philPct}% · {formatUsd(philUsd)} · ${philPerWorker.toFixed(2)}/worker |
| </span> |
| </div> |
| <div style={{ position: 'relative', height: '6px', background: '#fcfaf7', border: '1px solid #e8e5e1' }}> |
| <div |
| style={{ |
| position: 'absolute', |
| inset: 0, |
| width: `${philPct}%`, |
| background: '#8a3d28', |
| }} |
| /> |
| </div> |
| <input |
| type="range" |
| min={0} |
| max={100} |
| step={5} |
| value={philPct} |
| onChange={(e) => adjustSplit('phil', Number(e.target.value))} |
| style={{ width: '100%', marginTop: '8px', accentColor: '#8a3d28' }} |
| /> |
| </div> |
| |
| {/* Insurance row */} |
| <div style={{ marginBottom: '20px' }}> |
| <div |
| style={{ |
| display: 'flex', |
| alignItems: 'baseline', |
| justifyContent: 'space-between', |
| marginBottom: '6px', |
| }} |
| > |
| <span |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '13px', |
| fontWeight: 500, |
| color: '#1b1e2d', |
| }} |
| > |
| Insurance / Government |
| </span> |
| <span |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '13px', |
| color: '#606373', |
| fontVariantNumeric: 'tabular-nums', |
| }} |
| > |
| {insPct}% · {formatUsd(insUsd)} · ${insPerWorker.toFixed(2)}/worker |
| </span> |
| </div> |
| <div style={{ position: 'relative', height: '6px', background: '#fcfaf7', border: '1px solid #e8e5e1' }}> |
| <div |
| style={{ |
| position: 'absolute', |
| inset: 0, |
| width: `${insPct}%`, |
| background: '#606373', |
| }} |
| /> |
| </div> |
| <input |
| type="range" |
| min={0} |
| max={100} |
| step={5} |
| value={insPct} |
| onChange={(e) => adjustSplit('ins', Number(e.target.value))} |
| style={{ width: '100%', marginTop: '8px', accentColor: '#606373' }} |
| /> |
| </div> |
| |
| {/* Worker row */} |
| <div> |
| <div |
| style={{ |
| display: 'flex', |
| alignItems: 'baseline', |
| justifyContent: 'space-between', |
| marginBottom: '6px', |
| }} |
| > |
| <span |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '13px', |
| fontWeight: 500, |
| color: '#8d909e', |
| }} |
| > |
| Worker contribution |
| </span> |
| <span |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '13px', |
| color: '#606373', |
| fontVariantNumeric: 'tabular-nums', |
| }} |
| > |
| {workerPct}% · {formatUsd(workerUsd)} · ${workerPerYear.toFixed(2)}/worker/yr |
| </span> |
| </div> |
| <div style={{ position: 'relative', height: '6px', background: '#fcfaf7', border: '1px solid #e8e5e1' }}> |
| <div |
| style={{ |
| position: 'absolute', |
| inset: 0, |
| width: `${workerPct}%`, |
| background: '#8d909e', |
| }} |
| /> |
| </div> |
| <input |
| type="range" |
| min={0} |
| max={100} |
| step={5} |
| value={workerPct} |
| onChange={(e) => adjustSplit('worker', Number(e.target.value))} |
| style={{ width: '100%', marginTop: '8px', accentColor: '#8d909e' }} |
| /> |
| </div> |
| </div> |
| |
| {/* Section 4 — Distribution */} |
| <div data-tour="program-zones" style={{ marginBottom: '40px' }}> |
| <div className="section-header">Where it lands</div> |
| <p |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '12px', |
| color: '#8d909e', |
| marginTop: '-4px', |
| marginBottom: '16px', |
| maxWidth: '720px', |
| }} |
| > |
| Based on 20 years of climatology, this is the expected annual flow per |
| neighborhood. The column totals to the Annual program cost above. |
| </p> |
| <table className="etable"> |
| <thead> |
| <tr> |
| <th>Neighborhood</th> |
| <th className="num">Workers enrolled</th> |
| <th className="num">Trigger days / year</th> |
| <th className="num">Annual $ / worker</th> |
| <th className="num">Annual $ to neighborhood</th> |
| </tr> |
| </thead> |
| <tbody> |
| {[...zones] |
| .map((z) => ({ |
| z, |
| annualTotal: |
| (z.annual_cost_per_worker ?? 0) * (z.enrolled_workers ?? 0), |
| })) |
| .sort((a, b) => b.annualTotal - a.annualTotal) |
| .map(({ z, annualTotal }) => ( |
| <tr key={z.zone_id}> |
| <td> |
| <div style={{ color: '#1b1e2d' }}>{z.zone_name}</div> |
| <div style={{ fontSize: '11px', color: '#8d909e' }}> |
| {z.settlement_type} |
| </div> |
| </td> |
| <td className="num"> |
| {(z.enrolled_workers ?? 0).toLocaleString()} |
| </td> |
| <td className="num">{z.annual_trigger_days ?? 0}</td> |
| <td className="num">${z.annual_cost_per_worker ?? 0}</td> |
| <td |
| className="num" |
| style={{ fontWeight: 500, color: '#1b1e2d' }} |
| > |
| {`$${annualTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })}`} |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| |
| {/* Model disclosure (collapsible) */} |
| <div> |
| <button |
| onClick={() => setShowModel(!showModel)} |
| className="text-link" |
| style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#606373' }} |
| > |
| {showModel ? <ChevronDown size={14} /> : <ChevronRight size={14} />} |
| About this model |
| </button> |
| {showModel && ( |
| <div |
| style={{ |
| marginTop: '16px', |
| paddingTop: '16px', |
| borderTop: '1px solid #e8e5e1', |
| maxWidth: '640px', |
| }} |
| > |
| <p |
| style={{ |
| fontFamily: '"Space Grotesk", system-ui, sans-serif', |
| fontSize: '13px', |
| lineHeight: 1.7, |
| color: '#606373', |
| }} |
| > |
| Empirical burn analysis — the same approach operational |
| parametric insurers (ARC, CCRIF, SEWA pilot) use. Twenty years |
| of ERA5-Land reanalysis data are replayed through the trigger |
| rules, with zone-specific urban heat island correction, to |
| count how often each tier would have paid out historically. |
| Expected annual loss × loading factors (basis risk, admin, |
| contingency) gives the loaded premium, then the SEWA funding |
| split allocates cost across workers, philanthropy, and insurer. |
| </p> |
| </div> |
| )} |
| </div> |
| </div> |
| ) |
| } |
|
|