diff --git a/crossroads/api.py b/crossroads/api.py index c46e455..1c79a71 100644 --- a/crossroads/api.py +++ b/crossroads/api.py @@ -3,7 +3,7 @@ from flask import Flask, jsonify, request from flask_cors import CORS -from .compare import calculate_cliff_analysis, compare +from .compare import calculate_cliff_analysis, compare, compare_multiple from .events import ( ChildAgingOut, Divorce, @@ -157,16 +157,40 @@ def format_result_for_frontend(result) -> dict: @app.route("/api/simulate", methods=["POST"]) def simulate(): - """Run a life event simulation.""" + """Run a life event simulation (single or multiple events).""" try: data = request.get_json() household = create_household_from_request(data.get("household", {})) - event = create_event_from_request( - data.get("lifeEvent", {}).get("type"), - data.get("lifeEvent", {}).get("params", {}), - household, - ) - result = compare(household, event) + + # Support both single event and multiple events + life_events = data.get("lifeEvents", []) + single_event = data.get("lifeEvent") + + if life_events: + # Multiple events mode + events = [] + current_household = household + for evt in life_events: + event = create_event_from_request( + evt.get("type"), + evt.get("params", {}), + current_household, + ) + events.append(event) + # Update household for next event's context + current_household = event.apply(current_household) + result = compare_multiple(household, events) + elif single_event: + # Single event mode (backwards compatible) + event = create_event_from_request( + single_event.get("type"), + single_event.get("params", {}), + household, + ) + result = compare(household, event) + else: + raise ValueError("No life event provided") + return jsonify(format_result_for_frontend(result)) except ValueError as e: return jsonify({"error": str(e)}), 400 diff --git a/crossroads/compare.py b/crossroads/compare.py index b3b8261..cc3efd6 100644 --- a/crossroads/compare.py +++ b/crossroads/compare.py @@ -533,6 +533,74 @@ def compare( ) +@dataclass +class CombinedEvent(LifeEvent): + """A wrapper that combines multiple life events into one.""" + + events: list[LifeEvent] = field(default_factory=list) + + @property + def name(self) -> str: + if not self.events: + return "No events" + return " + ".join(e.name for e in self.events) + + @property + def description(self) -> str: + if not self.events: + return "No life events selected" + return ", ".join(e.description for e in self.events) + + def apply(self, household: Household) -> Household: + """Apply all events in sequence.""" + current = household + for event in self.events: + current = event.apply(current) + return current + + def validate(self, household: Household) -> list[str]: + """Validate all events can be applied in sequence.""" + errors = [] + current = household + for i, event in enumerate(self.events): + event_errors = event.validate(current) + if event_errors: + errors.extend([f"Event {i+1} ({event.name}): {e}" for e in event_errors]) + else: + # Apply event to validate next one against updated household + current = event.apply(current) + return errors + + +def compare_multiple( + household: Household, + events: list[LifeEvent], + variables: list[str] | None = None, +) -> ComparisonResult: + """ + Compare a household before and after multiple life events. + + Events are applied in sequence, and the result shows the baseline + household compared to the final state after all events. + + Args: + household: The baseline household before events. + events: List of life events to apply in order. + variables: Optional list of variables to compare. + + Returns: + A ComparisonResult with the combined event. + """ + if not events: + raise ValueError("At least one life event is required") + + if len(events) == 1: + return compare(household, events[0], variables) + + combined = CombinedEvent(events=events) + return compare(household, combined, variables) + + def calculate_cliff_analysis( household: Household, income_min: int = 0, diff --git a/frontend/src/app/icon.svg b/frontend/src/app/icon.svg index ed29421..db14b34 100644 --- a/frontend/src/app/icon.svg +++ b/frontend/src/app/icon.svg @@ -1,3 +1,5 @@ - πŸ”€ + + + diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ee06a05..081a742 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,17 +1,24 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; -import HouseholdForm from '@/components/HouseholdForm'; -import LifeEventSelector from '@/components/LifeEventSelector'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import HouseholdWizard from '@/components/HouseholdWizard'; +import ChangeWizard from '@/components/ChangeWizard'; import ResultsView from '@/components/ResultsView'; -import { Household, LifeEventType, SimulationResult } from '@/types'; +import { Household, LifeEventType, SimulationResult, SelectedEvent, LIFE_EVENTS, US_STATES } from '@/types'; -type Step = 'household' | 'event' | 'results'; +interface SavedScenario { + id: string; + events: SelectedEvent[]; + result: SimulationResult; +} + +function newScenarioId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} -// URL encoding/decoding helpers +// URL encoding/decoding helpers (single-event scenarios only) function encodeScenario(household: Household, event: LifeEventType, params: Record): string { - const data = { h: household, e: event, p: params }; - return btoa(JSON.stringify(data)); + return btoa(JSON.stringify({ h: household, e: event, p: params })); } function decodeScenario(encoded: string): { household: Household; event: LifeEventType; params: Record } | null { @@ -39,80 +46,56 @@ function decodeScenario(encoded: string): { household: Household; event: LifeEve return null; } -const DEFAULT_HOUSEHOLD: Household = { - state: 'CA', - filingStatus: 'single', - income: 50000, - spouseIncome: 0, - spouseAge: 30, - childAges: [], - age: 30, - hasESI: false, - spouseHasESI: false, - year: 2025, -}; - -const STEPS = [ - { key: 'household' as const, label: 'Household', number: 1 }, - { key: 'event' as const, label: 'Life Event', number: 2 }, - { key: 'results' as const, label: 'Results', number: 3 }, -]; - export default function Home() { - const [step, setStep] = useState('household'); - const [household, setHousehold] = useState(DEFAULT_HOUSEHOLD); - const [selectedEvent, setSelectedEvent] = useState(null); - const [eventParams, setEventParams] = useState>({}); - const [result, setResult] = useState(null); + const [household, setHousehold] = useState(null); + const [partialHousehold, setPartialHousehold] = useState>({}); + const [appliedEvents, setAppliedEvents] = useState([]); + const [scenarios, setScenarios] = useState([]); + const [currentScenarioId, setCurrentScenarioId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [shareUrl, setShareUrl] = useState(null); const [showCopied, setShowCopied] = useState(false); - // Run simulation with given parameters - const runSimulation = useCallback(async ( - h: Household, - event: LifeEventType, - params: Record - ) => { + const result = scenarios.find((s) => s.id === currentScenarioId)?.result ?? null; + const otherScenarios = scenarios.filter((s) => s.id !== currentScenarioId); + + const runSimulation = useCallback(async (h: Household, events: SelectedEvent[]) => { + if (events.length === 0) return; setIsLoading(true); setError(null); - setResult(null); - setStep('results'); - try { + // Single events use the lifeEvent field; combined scenarios send the + // ordered lifeEvents list. + const body: Record = { household: h }; + if (events.length === 1) { + body.lifeEvent = { type: events[0].type, params: events[0].params }; + } else { + body.lifeEvents = events.map((e) => ({ type: e.type, params: e.params })); + } + const response = await fetch('/api/simulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - household: h, - lifeEvent: { type: event, params }, - }), + body: JSON.stringify(body), }); - - if (!response.ok) { - throw new Error('Simulation failed'); - } - const data = await response.json(); + if (data.error) throw new Error(data.error); + if (!data.before || !data.after) throw new Error('Invalid response from simulation'); - // Check if API returned an error - if (data.error) { - throw new Error(data.error); - } + const id = newScenarioId(); + setScenarios((prev) => [...prev, { id, events, result: data }]); + setCurrentScenarioId(id); - // Validate the response has expected structure - if (!data.before || !data.after) { - throw new Error('Invalid response from simulation'); + // Shareable link (single events only for now) + if (events.length === 1) { + const encoded = encodeScenario(h, events[0].type, events[0].params); + setShareUrl(`${window.location.origin}?s=${encoded}`); + window.history.replaceState({}, '', `?s=${encoded}`); + } else { + setShareUrl(null); + window.history.replaceState({}, '', window.location.pathname); } - - setResult(data); - - // Update URL with shareable link - const encoded = encodeScenario(h, event, params); - const url = `${window.location.origin}?s=${encoded}`; - setShareUrl(url); - window.history.replaceState({}, '', `?s=${encoded}`); } catch (err) { setError(err instanceof Error ? err.message : 'Something went wrong'); } finally { @@ -120,392 +103,521 @@ export default function Home() { } }, []); - // Check URL for shared scenario on page load + const restoreScenario = useCallback((s: SavedScenario, h: Household) => { + setCurrentScenarioId(s.id); + setAppliedEvents(s.events); + setError(null); + if (s.events.length === 1) { + const encoded = encodeScenario(h, s.events[0].type, s.events[0].params); + setShareUrl(`${window.location.origin}?s=${encoded}`); + window.history.replaceState({}, '', `?s=${encoded}`); + } else { + setShareUrl(null); + window.history.replaceState({}, '', window.location.pathname); + } + }, []); + + const removeScenario = useCallback((id: string) => { + setScenarios((prev) => prev.filter((s) => s.id !== id)); + setCurrentScenarioId((curr) => (curr === id ? null : curr)); + }, []); + + // Check URL for a shared scenario on page load. The ref guards against + // StrictMode double-invocation creating duplicate scenarios. + const bootstrapped = useRef(false); useEffect(() => { + if (bootstrapped.current) return; + bootstrapped.current = true; const params = new URLSearchParams(window.location.search); const encoded = params.get('s'); if (encoded) { const scenario = decodeScenario(encoded); if (scenario) { + const events = [{ type: scenario.event, params: scenario.params }]; setHousehold(scenario.household); - setSelectedEvent(scenario.event); - setEventParams(scenario.params); - // Auto-run simulation - runSimulation(scenario.household, scenario.event, scenario.params); + setAppliedEvents(events); + runSimulation(scenario.household, events); } } }, [runSimulation]); - const handleSimulate = async () => { - if (!selectedEvent) return; - await runSimulation(household, selectedEvent, eventParams); + const handleWizardComplete = (h: Household) => { + setHousehold(h); + setAppliedEvents([]); + setScenarios([]); + setCurrentScenarioId(null); + setError(null); + }; + + const handleRun = () => { + if (household && appliedEvents.length > 0) { + runSimulation(household, appliedEvents); + } }; const handleShare = async () => { - if (shareUrl) { - try { - await navigator.clipboard.writeText(shareUrl); - setShowCopied(true); - setTimeout(() => setShowCopied(false), 2000); - } catch { - // Fallback for browsers that don't support clipboard API - const input = document.createElement('input'); - input.value = shareUrl; - document.body.appendChild(input); - input.select(); - document.execCommand('copy'); - document.body.removeChild(input); - setShowCopied(true); - setTimeout(() => setShowCopied(false), 2000); - } + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + } catch { + const input = document.createElement('input'); + input.value = shareUrl; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); } + setShowCopied(true); + setTimeout(() => setShowCopied(false), 2000); }; - const handleReset = () => { - setStep('household'); - setSelectedEvent(null); - setEventParams({}); - setResult(null); + const handleTryAnother = () => { + // Keep household + scenario history; clear the active result so the + // user can model a new what-if. + setAppliedEvents([]); + setCurrentScenarioId(null); setError(null); setShareUrl(null); - // Clear URL params window.history.replaceState({}, '', window.location.pathname); }; - const canProceedToEvent = household.income >= 0; - const canSimulate = selectedEvent !== null; + const handleReset = () => { + setHousehold(null); + setPartialHousehold({}); + setAppliedEvents([]); + setScenarios([]); + setCurrentScenarioId(null); + setError(null); + setShareUrl(null); + window.history.replaceState({}, '', window.location.pathname); + }; - const currentStepIndex = STEPS.findIndex(s => s.key === step); + const eventsLabel = describeEvents(appliedEvents); return ( -
- {/* Hero Section */} - {step === 'household' && ( -
-
-
-

- How will life changes affect your finances? -

-

- Explore how major life events impact your taxes, benefits, and net income - using PolicyEngine's simulation engine. -

-
-
- )} - - {/* Progress Steps */} -
-
-
- {STEPS.map((s, i) => { - const isActive = s.key === step; - const isCompleted = currentStepIndex > i; - // Allow navigation: always to step 1, to step 2 if household exists, to step 3 if results exist - const canNavigate = - s.key === 'household' || - (s.key === 'event' && canProceedToEvent) || - (s.key === 'results' && result !== null); - - return ( -
+
+
+ + {/* Hero card */} +
+ {result && appliedEvents.length > 0 && household ? ( + <> +
+ + {eventsLabel} +
+

+ {getHeroHeadline(appliedEvents, result)} +

+

+ {householdSummaryLine(household)} +

+ {(() => { + const changes = appliedEvents.flatMap((e) => + describeScenarioChanges(e.type, e.params, household), + ); + if (changes.length === 0) return null; + return ( +
+
+ What changed +
+
+ {changes.map((c, i) => ( +
+ {c.label} + {c.before !== undefined && c.after !== undefined ? ( + <> + {c.before} + to + {c.after} + + ) : ( + {c.text} + )} +
+ ))} +
+
+ ); + })()} +
+ Tax year: {household.year} + {shareUrl && ( - {i < STEPS.length - 1 && ( -
i ? 'bg-[#319795]' : 'bg-[#E2E8F0]' - }`} - /> - )} + )} +
+ + ) : household ? ( +
+
+
+ + Your household
- ); - })} -
+

+ What if something changed? +

+

+ Pick a life event below to see how it would affect your taxes, benefits, and + net income. +

+
+ +
+ ) : ( + <> +
+ + New scenario +
+

+ See how a life event changes your taxes and benefits. +

+

+ Tell us about your household and we'll model it with PolicyEngine's + simulation engine. +

+ + )}
-
- {/* Main Content */} -
- {/* Error Display */} - {error && ( -
- - - - {error} -
+ {/* Household wizard (no household yet) */} + {!household && ( + <> + + + )} - {/* Step Content */} - {step === 'household' && ( -
- -
- + {/* Household entered: show change wizard until results are ready */} + {household && !isLoading && !result && !error && ( + { + setAppliedEvents(events); + runSimulation(household, events); + }} + /> + )} + + {/* Loading */} + {isLoading && ( +
+
+
+
+

+ Calculating how {eventsLabel.toLowerCase()} affects your taxes and benefits… +

)} - {step === 'event' && ( -
- -
+ {/* Error */} + {!isLoading && error && ( +
+

Simulation failed

+

{error}

+
- +
)} - {step === 'results' && ( -
- {isLoading ? ( - /* Loading State */ -
-
-
-
-
-
-

Running Simulation

-

- Calculating how {selectedEvent?.replace(/_/g, ' ')} will affect your taxes and benefits... -

-
-
- ) : error ? ( - /* Error State */ -
-
-
- - - -
-

Simulation Failed

-

- {error} -

-
- - -
-
-
- ) : result ? ( - <> - {/* Scenario Summary */} -
-
-

Your Scenario

-
- - | - - | - -
-
-
-
- State -

- {selectedEvent === 'moving_states' && eventParams.newState - ? `${household.state} β†’ ${eventParams.newState}` - : household.state} -

-
-
- Tax Year -

- {household.year || 2025} -

-
-
- Filing Status -

- {household.filingStatus.replace(/_/g, ' ')} -

-
-
- Life Event -

- {selectedEvent?.replace(/_/g, ' ')} -

-
-
-
-
- Your Income -

- {selectedEvent === 'changing_income' && eventParams.newIncome !== undefined - ? `$${household.income.toLocaleString()} β†’ $${(eventParams.newIncome as number).toLocaleString()}` - : `$${household.income.toLocaleString()}`} -

-
- {(household.filingStatus === 'married_jointly' || household.filingStatus === 'married_separately') && ( -
- Spouse Income -

- {selectedEvent === 'changing_income' && eventParams.newSpouseIncome !== undefined - ? `$${(household.spouseIncome ?? 0).toLocaleString()} β†’ $${(eventParams.newSpouseIncome as number).toLocaleString()}` - : `$${(household.spouseIncome ?? 0).toLocaleString()}`} -

-
- )} - {household.childAges.length > 0 && ( -
- Children -

- {household.childAges.length} (ages {household.childAges.join(', ')}) -

-
- )} -
-
+ {/* Results */} + {!isLoading && !error && result && household && ( +
+ +
+ )} - - - ) : null} + {/* Saved scenarios: older results from this household */} + {household && otherScenarios.length > 0 && ( +
+
+ Compared scenarios +
+
+ {otherScenarios.map((s) => ( + restoreScenario(s, household)} + onRemove={() => removeScenario(s.id)} + /> + ))} +
)} + + {/* Footer */} +
+
+ ); +} + +function describeEvents(events: SelectedEvent[]): string { + if (events.length === 0) return 'This change'; + if (events.length === 1) { + return LIFE_EVENTS.find((e) => e.type === events[0].type)?.label ?? events[0].type; + } + return `${events.length} combined changes`; +} + +function householdSummaryLine(h: Household): string { + const married = h.filingStatus === 'married_jointly' || h.filingStatus === 'married_separately'; + const stateName = US_STATES.find((s) => s.code === h.state)?.name ?? h.state; + const totalIncome = h.income + (married ? h.spouseIncome ?? 0 : 0); + const parts = [ + stateName, + married ? `Married, ages ${h.age} & ${h.spouseAge}` : `Single, age ${h.age}`, + `$${totalIncome.toLocaleString()}/yr household income`, + ]; + if (h.childAges.length > 0) { + parts.push(`${h.childAges.length} child${h.childAges.length > 1 ? 'ren' : ''}`); + } + return parts.join(' Β· '); +} - {/* Footer */} - +function PartialSummary({ partial }: { partial: Partial }) { + const items: { label: string; value: string }[] = []; + if (partial.state) { + items.push({ + label: 'State', + value: US_STATES.find((s) => s.code === partial.state)?.name ?? partial.state, + }); + } + if (partial.year) { + items.push({ label: 'Tax year', value: String(partial.year) }); + } + if (partial.filingStatus) { + const labels: Record = { + single: 'Single', + married_jointly: 'Married filing jointly', + married_separately: 'Married filing separately', + head_of_household: 'Head of household', + }; + items.push({ label: 'Filing', value: labels[partial.filingStatus] }); + } + if (partial.age && partial.age > 0) { + const married = + partial.filingStatus === 'married_jointly' || partial.filingStatus === 'married_separately'; + items.push({ + label: 'Age', + value: married && partial.spouseAge ? `${partial.age} & ${partial.spouseAge}` : `${partial.age}`, + }); + } + if (partial.income !== undefined && partial.income > 0) { + const total = partial.income + (partial.spouseIncome ?? 0); + items.push({ label: 'Income', value: `$${total.toLocaleString()}/yr` }); + } + if (partial.hasESI !== undefined || partial.spouseHasESI !== undefined) { + items.push({ + label: 'Job coverage', + value: partial.hasESI || partial.spouseHasESI ? 'Yes' : 'No', + }); + } + if (partial.childAges && partial.childAges.length > 0) { + items.push({ + label: 'Children', + value: `${partial.childAges.length} (ages ${partial.childAges.join(', ')})`, + }); + } + + if (items.length === 0) return null; + + return ( +
+
+ So far +
+
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ ); +} + +interface ScenarioCardProps { + scenario: SavedScenario; + onClick: () => void; + onRemove: () => void; +} + +function ScenarioCard({ scenario, onClick, onRemove }: ScenarioCardProps) { + const label = describeEvents(scenario.events); + const delta = scenario.result.diff?.netIncome ?? 0; + const isGain = delta > 0.5; + const isLoss = delta < -0.5; + const tone = isGain ? 'text-green-600' : isLoss ? 'text-red-600' : 'text-gray-500'; + const sign = isGain ? '+' : isLoss ? 'βˆ’' : ''; + const amount = `$${Math.abs(Math.round(delta)).toLocaleString()}`; + return ( +
+ +
); } + +// Structured "what changed" entries for the result hero. Each entry has a +// label and either a before/after pair or a single text value. Skips fields +// the user didn't actually change. +interface ScenarioChange { + label: string; + before?: string; + after?: string; + text?: string; +} + +function describeScenarioChanges( + eventType: LifeEventType, + params: Record, + household: Household, +): ScenarioChange[] { + const fmt = (annual: number) => `$${Math.round(annual).toLocaleString()}/yr`; + const out: ScenarioChange[] = []; + + switch (eventType) { + case 'having_baby': { + const n = (params.numBabies as number) ?? 1; + out.push({ label: 'New baby', text: n === 1 ? '1 baby' : `${n} babies` }); + return out; + } + case 'getting_married': { + const spouseAge = params.spouseAge as number | undefined; + const spouseIncome = params.spouseIncome as number | undefined; + const spouseChildAges = (params.spouseChildAges as number[] | undefined) ?? []; + const parts: string[] = []; + if (spouseAge) parts.push(`age ${spouseAge}`); + if (spouseIncome !== undefined && spouseIncome > 0) parts.push(`${fmt(spouseIncome)} income`); + if (spouseChildAges.length > 0) { + parts.push(`${spouseChildAges.length} child${spouseChildAges.length > 1 ? 'ren' : ''}`); + } + out.push({ label: 'Adding spouse', text: parts.length > 0 ? parts.join(', ') : 'New spouse' }); + return out; + } + case 'divorce': { + const childrenKeeping = params.childrenKeeping as number | undefined; + if (household.childAges.length > 0 && childrenKeeping !== undefined) { + const leaving = household.childAges.length - childrenKeeping; + out.push({ + label: 'Children', + text: `${childrenKeeping} of ${household.childAges.length} stay with you${leaving > 0 ? `, ${leaving} with ex-spouse` : ''}`, + }); + } + out.push({ label: 'Separating', text: 'from spouse' }); + return out; + } + case 'moving_states': { + const newState = (params.newState as string) ?? household.state; + if (newState !== household.state) { + out.push({ label: 'State', before: household.state, after: newState }); + } + return out; + } + case 'changing_income': { + const newIncome = (params.newIncome as number) ?? household.income; + if (newIncome !== household.income) { + out.push({ label: 'Your income', before: fmt(household.income), after: fmt(newIncome) }); + } + const newSpouseIncome = params.newSpouseIncome as number | undefined; + if (newSpouseIncome !== undefined && newSpouseIncome !== (household.spouseIncome ?? 0)) { + out.push({ + label: 'Spouse income', + before: fmt(household.spouseIncome ?? 0), + after: fmt(newSpouseIncome), + }); + } + return out; + } + case 'losing_esi': + out.push({ label: 'Employer coverage', text: 'ending' }); + return out; + default: + return out; + } +} + +// Build the hero headline from the net income delta in the result so the +// copy can't contradict the numbers beneath it. +function getHeroHeadline(events: SelectedEvent[], result: SimulationResult): string { + const subject = describeEvents(events); + const delta = result.diff?.netIncome ?? 0; + const amount = `$${Math.abs(Math.round(delta)).toLocaleString()}`; + if (delta > 0.5) return `${subject} raises your annual net income by ${amount}.`; + if (delta < -0.5) return `${subject} lowers your annual net income by ${amount}.`; + return `${subject} leaves your annual net income unchanged.`; +} diff --git a/frontend/src/components/ChangeWizard.tsx b/frontend/src/components/ChangeWizard.tsx new file mode 100644 index 0000000..349b4b1 --- /dev/null +++ b/frontend/src/components/ChangeWizard.tsx @@ -0,0 +1,491 @@ +'use client'; + +import { useState } from 'react'; +import { Household, LifeEventType, LIFE_EVENTS, US_STATES, SelectedEvent } from '@/types'; + +interface ChangeWizardProps { + household: Household; + onApply: (events: SelectedEvent[]) => void; +} + +function isMarried(h: Household) { + return h.filingStatus === 'married_jointly' || h.filingStatus === 'married_separately'; +} + +// Events that make sense for this household. Marriage is hidden for married +// filers, divorce for single filers, and losing employer coverage when +// nobody has it. +function getAvailableEvents(h: Household): typeof LIFE_EVENTS { + return LIFE_EVENTS.filter((e) => { + if (e.type === 'getting_married') return !isMarried(h); + if (e.type === 'divorce') return isMarried(h); + if (e.type === 'losing_esi') return h.hasESI || h.spouseHasESI; + return true; + }); +} + +function FieldLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function MoneyInput({ + value, + onChange, + autoFocus = false, +}: { + value: string; + onChange: (v: string) => void; + autoFocus?: boolean; +}) { + return ( +
+ $ + onChange(e.target.value.replace(/[^0-9]/g, ''))} + autoFocus={autoFocus} + className="flex-1 text-base px-3 py-2.5 focus:outline-none min-w-0" + /> + /yr +
+ ); +} + +function inputClass() { + return 'w-full text-base border border-gray-200 rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-[#319795]/30 focus:border-[#319795] bg-white'; +} + +export default function ChangeWizard({ household, onApply }: ChangeWizardProps) { + const [step, setStep] = useState<1 | 2>(1); + const [eventType, setEventType] = useState(null); + // Events already configured via "Add another change"; applied in order + // before the one currently being edited. + const [queued, setQueued] = useState([]); + + const married = isMarried(household); + + // having_baby + const [numBabies, setNumBabies] = useState(1); + + // getting_married + const [spouseAge, setSpouseAge] = useState('30'); + const [spouseIncome, setSpouseIncome] = useState(''); + const [spouseHasESI, setSpouseHasESI] = useState(false); + const [spouseChildAges, setSpouseChildAges] = useState>([]); + + // divorce + const [childrenKeeping, setChildrenKeeping] = useState(household.childAges.length); + + // moving_states + const [newState, setNewState] = useState(''); + + // changing_income + const [newIncome, setNewIncome] = useState(String(household.income)); + const [newSpouseIncome, setNewSpouseIncome] = useState(String(household.spouseIncome ?? 0)); + + const events = getAvailableEvents(household); + + function resetEventFields() { + setNumBabies(1); + setSpouseAge('30'); + setSpouseIncome(''); + setSpouseHasESI(false); + setSpouseChildAges([]); + setChildrenKeeping(household.childAges.length); + setNewState(''); + setNewIncome(String(household.income)); + setNewSpouseIncome(String(household.spouseIncome ?? 0)); + } + + function pickEvent(t: LifeEventType) { + setEventType(t); + setStep(2); + } + + function buildParams(): Record { + switch (eventType) { + case 'having_baby': + return { numBabies }; + case 'getting_married': + return { + spouseAge: parseInt(spouseAge, 10) || 30, + spouseIncome: parseInt(spouseIncome, 10) || 0, + spouseHasESI, + spouseChildAges: spouseChildAges.map((a) => (a === '' ? 0 : a)), + }; + case 'divorce': + return { childrenKeeping }; + case 'moving_states': + return { newState }; + case 'changing_income': { + const params: Record = { + newIncome: parseInt(newIncome, 10) || 0, + }; + if (married) params.newSpouseIncome = parseInt(newSpouseIncome, 10) || 0; + return params; + } + case 'losing_esi': + return {}; + default: + return {}; + } + } + + function canApply(): boolean { + switch (eventType) { + case 'changing_income': { + if (newIncome === '') return false; + const changedSelf = (parseInt(newIncome, 10) || 0) !== household.income; + const changedSpouse = + married && (parseInt(newSpouseIncome, 10) || 0) !== (household.spouseIncome ?? 0); + return changedSelf || changedSpouse; + } + case 'moving_states': + return newState !== '' && newState !== household.state; + case 'getting_married': + return spouseAge !== '' && parseInt(spouseAge, 10) >= 18; + case 'having_baby': + case 'divorce': + case 'losing_esi': + return true; + default: + return false; + } + } + + function addAnother() { + if (!eventType || !canApply()) return; + setQueued((prev) => [...prev, { type: eventType, params: buildParams() }]); + setEventType(null); + resetEventFields(); + setStep(1); + } + + function run() { + if (eventType && canApply()) { + onApply([...queued, { type: eventType, params: buildParams() }]); + } else if (queued.length > 0) { + onApply(queued); + } + } + + function removeQueued(index: number) { + setQueued((prev) => prev.filter((_, i) => i !== index)); + } + + const eventLabel = (t: LifeEventType) => LIFE_EVENTS.find((e) => e.type === t)?.label ?? t; + + // Display step number continues from the household wizard (steps 1-6). + const wizardStep = step === 1 ? 7 : 8; + const progressPercent = (wizardStep / 8) * 100; + + return ( +
+
+
+ Step {wizardStep} of 8 +
+
+
+
+
+ +
+ {queued.length > 0 && ( +
+
+ Changes so far (applied in order) +
+
+ {queued.map((evt, idx) => ( + + {eventLabel(evt.type)} + + + ))} +
+
+ )} + + {step === 1 && ( + <> +
+

+ {queued.length > 0 ? 'What else is changing?' : "What's changing?"} +

+

+ {queued.length > 0 + ? 'Pick another life event, or run the scenario with the changes above.' + : 'Pick the life event you want to model.'} +

+
+
+ {events.map((opt) => ( + + ))} +
+ {queued.length > 0 && ( +
+ +
+ )} + + )} + + {step === 2 && eventType && ( +
{ + e.preventDefault(); + if (canApply()) run(); + }} + > +
+

{getStep2Heading(eventType)}

+
+ +
+ {eventType === 'having_baby' && ( +
+ Number of babies + +
+ )} + + {eventType === 'getting_married' && ( + <> +
+ Spouse's age + setSpouseAge(e.target.value)} + autoFocus + className={inputClass()} + /> +
+
+ Spouse's annual income + +
+ +
+ Spouse's children (under 18) +
+ {spouseChildAges.length === 0 && ( +

None

+ )} + {spouseChildAges.map((childAge, i) => ( +
+ Child {i + 1} + { + const next = [...spouseChildAges]; + const v = e.target.value; + if (v === '') next[i] = ''; + else { + const n = parseInt(v, 10); + next[i] = isNaN(n) ? '' : Math.min(17, Math.max(0, n)); + } + setSpouseChildAges(next); + }} + className={inputClass()} + placeholder="Age" + /> + +
+ ))} + +
+
+ + )} + + {eventType === 'divorce' && ( + <> + {household.childAges.length > 0 ? ( +
+ + Children staying with you (of {household.childAges.length}) + + + setChildrenKeeping( + Math.min( + household.childAges.length, + Math.max(0, parseInt(e.target.value, 10) || 0), + ), + ) + } + autoFocus + className={inputClass()} + /> +
+ ) : ( +

+ Click Run scenario to model the divorce. +

+ )} + + )} + + {eventType === 'moving_states' && ( +
+ New state + +
+ )} + + {eventType === 'changing_income' && ( + <> +
+ {married ? 'Your new annual income' : 'New annual income'} + +

+ Current: ${household.income.toLocaleString()} +

+
+ {married && ( +
+ Spouse's new annual income + +

+ Current: ${(household.spouseIncome ?? 0).toLocaleString()} +

+
+ )} + + )} + + {eventType === 'losing_esi' && ( +

+ Employer health insurance ends for everyone who has it. Click Run scenario to + see what changes. +

+ )} +
+ +
+ +
+ + +
+
+
+ )} +
+
+ ); +} + +function getStep2Heading(eventType: LifeEventType): string { + switch (eventType) { + case 'having_baby': return 'Tell us about the new arrival'; + case 'getting_married': return 'Tell us about your future spouse'; + case 'divorce': return 'A few details about the split'; + case 'moving_states': return 'Where are you moving?'; + case 'changing_income': return "What's the new income?"; + case 'losing_esi': return 'Confirm'; + } +} diff --git a/frontend/src/components/CliffChart.tsx b/frontend/src/components/CliffChart.tsx index a157611..913a1bd 100644 --- a/frontend/src/components/CliffChart.tsx +++ b/frontend/src/components/CliffChart.tsx @@ -199,14 +199,14 @@ export default function CliffChart({ household }: CliffChartProps) { return (
-
- +
+
-

Analyzing Benefit Cliffs

+

Analyzing benefit cliffs

Calculating benefits across income levels...

@@ -232,13 +232,13 @@ export default function CliffChart({ household }: CliffChartProps) {
-
- +
+
-

Benefit Cliffs Analysis

+

Benefit cliffs analysis

How your benefits change as income increases

@@ -251,21 +251,21 @@ export default function CliffChart({ household }: CliffChartProps) { onClick={() => setViewMode('netIncome')} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${ viewMode === 'netIncome' - ? 'bg-purple-100 text-purple-700' + ? 'bg-[#E6FFFA] text-[#285E61]' : 'text-gray-500 hover:text-gray-700' }`} > - Net Income + Net income
@@ -290,24 +290,24 @@ export default function CliffChart({ household }: CliffChartProps) { .slice(0, 3); return ( -
+
- - + +
-

+

{cliffPoints.length} benefit cliff{cliffPoints.length !== 1 ? 's' : ''} detected

-

- At certain income levels, earning more could reduce your total resources. +

+ At certain income levels, earning more reduces total resources.

{topCauses.length > 0 && (
{topCauses.map(([program]) => ( {PROGRAM_LABELS[program] || program} @@ -327,8 +327,8 @@ export default function CliffChart({ household }: CliffChartProps) { - - + + @@ -348,7 +348,7 @@ export default function CliffChart({ household }: CliffChartProps) { @@ -411,10 +411,10 @@ export default function CliffChart({ household }: CliffChartProps) { {/* Current income marker */} {currentIncome > 0 && ( @@ -440,17 +440,17 @@ export default function CliffChart({ household }: CliffChartProps) { {/* Legend */}
-
- {viewMode === 'netIncome' ? 'Net Income' : 'Marginal Rate'} +
+ {viewMode === 'netIncome' ? 'Net income' : 'Marginal rate'}
- Your Current Income + Your current income
{viewMode === 'marginalRate' && (
- 100% = Benefit Cliff + 100% = benefit cliff
)}
@@ -460,12 +460,12 @@ export default function CliffChart({ household }: CliffChartProps) {

{viewMode === 'netIncome' ? ( <> - Net Income = Gross Income - Taxes + Benefits + Tax Credits. + Net income = gross income βˆ’ taxes + benefits + tax credits. The line shows how much you actually keep as your earnings change. ) : ( <> - Marginal Rate shows how much of each additional dollar you lose to taxes + Marginal rate shows how much of each additional dollar you lose to taxes and benefit phase-outs. Rates above 100% mean earning more leaves you worse off. )} diff --git a/frontend/src/components/HouseholdForm.tsx b/frontend/src/components/HouseholdForm.tsx deleted file mode 100644 index 44b2b56..0000000 --- a/frontend/src/components/HouseholdForm.tsx +++ /dev/null @@ -1,370 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Household, US_STATES } from '@/types'; - -interface HouseholdFormProps { - household: Household; - onChange: (household: Household) => void; - disabled?: boolean; -} - -export default function HouseholdForm({ - household, - onChange, - disabled = false, -}: HouseholdFormProps) { - const updateField = ( - field: K, - value: Household[K] - ) => { - onChange({ ...household, [field]: value }); - }; - - const isMarried = household.filingStatus === 'married_jointly' || household.filingStatus === 'married_separately'; - - const addChild = () => { - if (household.childAges.length < 10) { - updateField('childAges', [...household.childAges, 10]); - } - }; - - const removeChild = (index: number) => { - const newAges = household.childAges.filter((_, i) => i !== index); - updateField('childAges', newAges); - }; - - const updateChildAge = (index: number, age: number) => { - const newAges = [...household.childAges]; - newAges[index] = Math.min(17, Math.max(0, age)); - updateField('childAges', newAges); - }; - - // Track child age editing separately (field name + index) - const [editingChildIndex, setEditingChildIndex] = useState(null); - const [editingChildValue, setEditingChildValue] = useState(''); - - // Track which field is being edited (to allow empty during typing) - const [editingField, setEditingField] = useState(null); - const [editingValue, setEditingValue] = useState(''); - - // Format number with commas - const formatWithCommas = (value: number): string => { - return value.toLocaleString('en-US'); - }; - - // Parse number from string with commas - const parseFromCommas = (value: string): number => { - return parseInt(value.replace(/,/g, '')) || 0; - }; - - // Handle number input - allow empty during typing - const handleNumberChange = ( - field: 'income' | 'spouseIncome' | 'age' | 'spouseAge', - value: string, - ) => { - setEditingValue(value); - const parsed = parseInt(value.replace(/,/g, '')); - if (!isNaN(parsed)) { - updateField(field, parsed); - } - }; - - const handleNumberFocus = (field: string, currentValue: number) => { - setEditingField(field); - setEditingValue(String(currentValue)); - }; - - // Validate and clamp on blur - const handleNumberBlur = ( - field: 'income' | 'spouseIncome' | 'age' | 'spouseAge', - min: number, - max?: number - ) => { - const parsed = parseFromCommas(editingValue); - if (isNaN(parsed) || editingValue === '') { - updateField(field, min); - } else { - const clamped = max !== undefined ? Math.min(max, Math.max(min, parsed)) : Math.max(min, parsed); - updateField(field, clamped); - } - setEditingField(null); - setEditingValue(''); - }; - - // Get display value for income fields (with commas when not editing) - const getIncomeDisplayValue = (field: 'income' | 'spouseIncome', value: number): string => { - if (editingField === field) { - return editingValue; - } - return formatWithCommas(value); - }; - - return ( -

-

- Your Household -

- -
- {/* State, Year & Filing Status Row */} -
-
- - -
- -
- - -
- -
- - -
-
- - {/* Age & Income Row */} -
-
- - handleNumberChange('age', e.target.value)} - onFocus={() => handleNumberFocus('age', household.age)} - onBlur={() => handleNumberBlur('age', 18, 100)} - disabled={disabled} - className="input-field" - /> -
- -
- -
- $ - handleNumberChange('income', e.target.value)} - onFocus={() => handleNumberFocus('income', household.income)} - onBlur={() => handleNumberBlur('income', 0)} - disabled={disabled} - className="currency-field" - /> -
-
-
- - {/* ESI Checkbox */} - - - {/* Spouse Section */} - {isMarried && ( -
-

- Spouse Information -

- -
-
-
- - handleNumberChange('spouseAge', e.target.value)} - onFocus={() => handleNumberFocus('spouseAge', household.spouseAge || 30)} - onBlur={() => handleNumberBlur('spouseAge', 18, 100)} - disabled={disabled} - className="input-field" - /> -
- -
- -
- $ - handleNumberChange('spouseIncome', e.target.value)} - onFocus={() => handleNumberFocus('spouseIncome', household.spouseIncome ?? 0)} - onBlur={() => handleNumberBlur('spouseIncome', 0)} - disabled={disabled} - className="currency-field" - /> -
-
-
- - -
-
- )} - - {/* Children Section */} -
-
-

Children

- -
- - {household.childAges.length === 0 ? ( -

No children added

- ) : ( -
- {household.childAges.map((age, index) => ( -
- - Child {index + 1} - - { - setEditingChildValue(e.target.value); - const val = parseInt(e.target.value); - if (!isNaN(val)) { - updateChildAge(index, val); - } - }} - onFocus={() => { - setEditingChildIndex(index); - setEditingChildValue(String(age)); - }} - onBlur={() => { - const parsed = parseInt(editingChildValue); - if (isNaN(parsed) || editingChildValue === '') { - updateChildAge(index, 0); - } else { - updateChildAge(index, parsed); - } - setEditingChildIndex(null); - setEditingChildValue(''); - }} - disabled={disabled} - className="input-field flex-1 !py-2" - placeholder="Age" - /> - years - -
- ))} -
- )} -
-
-
- ); -} diff --git a/frontend/src/components/HouseholdWizard.tsx b/frontend/src/components/HouseholdWizard.tsx new file mode 100644 index 0000000..31cf8ec --- /dev/null +++ b/frontend/src/components/HouseholdWizard.tsx @@ -0,0 +1,508 @@ +'use client'; + +import { useState } from 'react'; +import { Household, US_STATES } from '@/types'; + +interface HouseholdWizardProps { + onComplete: (household: Household) => void; + onPartialChange?: (partial: Partial) => void; +} + +type FilingStatus = Household['filingStatus']; + +// The change wizard adds 2 more steps after household entry (event picker, +// event details). Display the household steps as part of the same 8-step +// flow so the user sees a continuous progress bar. +const TOTAL_STEPS_INCLUDING_CHANGE = 8; + +function isMarriedStatus(status: FilingStatus): boolean { + return status === 'married_jointly' || status === 'married_separately'; +} + +const FILING_OPTIONS: { label: string; value: FilingStatus }[] = [ + { label: 'Single', value: 'single' }, + { label: 'Married filing jointly', value: 'married_jointly' }, + { label: 'Married filing separately', value: 'married_separately' }, + { label: 'Head of household', value: 'head_of_household' }, +]; + +function optionClass(selected: boolean): string { + return `w-full text-left px-4 py-3 rounded-xl border-2 font-medium transition-all mb-2 ${ + selected + ? 'border-[#319795] bg-[#E6FFFA] text-[#285E61]' + : 'border-gray-200 text-gray-900 hover:border-[#319795] hover:bg-[#E6FFFA]' + }`; +} + +function MoneyInput({ + value, + onChange, + autoFocus = false, +}: { + value: string; + onChange: (v: string) => void; + autoFocus?: boolean; +}) { + return ( +
+ $ + onChange(e.target.value.replace(/[^0-9]/g, ''))} + autoFocus={autoFocus} + className="flex-1 text-base px-3 py-2.5 focus:outline-none min-w-0" + /> + /yr +
+ ); +} + +export default function HouseholdWizard({ onComplete, onPartialChange }: HouseholdWizardProps) { + const [step, setStep] = useState(1); + + const [state, setState] = useState(''); + const [year, setYear] = useState(2025); + + const [filingStatus, setFilingStatus] = useState('single'); + const [filingTouched, setFilingTouched] = useState(false); + + const [age, setAge] = useState(''); + const [spouseAge, setSpouseAge] = useState(''); + + const [income, setIncome] = useState(''); + const [spouseIncome, setSpouseIncome] = useState(''); + + const [hasESI, setHasESI] = useState(false); + const [spouseHasESI, setSpouseHasESI] = useState(false); + const [esiTouched, setEsiTouched] = useState(false); + + const [hasKids, setHasKids] = useState<'yes' | 'no' | null>(null); + const [childAges, setChildAges] = useState>([]); + + const married = isMarriedStatus(filingStatus); + + function buildPartial(overrides: Partial = {}): Partial { + const partial: Partial = { year }; + if (state) partial.state = state; + if (filingTouched) partial.filingStatus = filingStatus; + const myAge = parseInt(age, 10); + if (!isNaN(myAge) && myAge > 0) partial.age = myAge; + const mySpouseAge = parseInt(spouseAge, 10); + if (!isNaN(mySpouseAge) && mySpouseAge > 0) partial.spouseAge = mySpouseAge; + const myIncome = parseInt(income, 10); + if (!isNaN(myIncome)) partial.income = myIncome; + const mySpouseIncome = parseInt(spouseIncome, 10); + if (!isNaN(mySpouseIncome)) partial.spouseIncome = mySpouseIncome; + if (esiTouched) { + partial.hasESI = hasESI; + partial.spouseHasESI = spouseHasESI; + } + const enteredChildAges = childAges.map(Number).filter((n) => !isNaN(n)); + if (enteredChildAges.length > 0) partial.childAges = enteredChildAges; + return { ...partial, ...overrides }; + } + + function goBack() { + setStep((s) => Math.max(1, s - 1)); + } + + function goNext() { + setStep((s) => s + 1); + onPartialChange?.(buildPartial()); + } + + function selectFilingStatus(status: FilingStatus) { + setFilingStatus(status); + setFilingTouched(true); + setStep(3); + // Pass overrides because the React state setter above hasn't flushed yet. + onPartialChange?.(buildPartial({ filingStatus: status })); + } + + function selectESI(selfESI: boolean, partnerESI: boolean) { + setHasESI(selfESI); + setSpouseHasESI(partnerESI); + setEsiTouched(true); + setStep(6); + onPartialChange?.(buildPartial({ hasESI: selfESI, spouseHasESI: partnerESI })); + } + + function addChild() { + if (childAges.length < 10) setChildAges((prev) => [...prev, '']); + } + + function removeChild(index: number) { + setChildAges((prev) => prev.filter((_, i) => i !== index)); + } + + function updateChildAge(index: number, value: string) { + setChildAges((prev) => { + const next = [...prev]; + if (value === '') { + next[index] = ''; + } else { + const parsed = parseInt(value, 10); + next[index] = isNaN(parsed) ? '' : Math.min(17, Math.max(0, parsed)); + } + return next; + }); + } + + function handleComplete(finalChildAges: Array) { + const numericChildAges = finalChildAges.map((a) => (a === '' ? 0 : a)); + const household: Household = { + state: state || 'CA', + filingStatus, + age: parseInt(age, 10) || 30, + spouseAge: married ? parseInt(spouseAge, 10) || 30 : 30, + income: parseInt(income, 10) || 0, + spouseIncome: married ? parseInt(spouseIncome, 10) || 0 : 0, + hasESI, + spouseHasESI: married ? spouseHasESI : false, + childAges: numericChildAges, + year, + }; + onComplete(household); + } + + const progressPercent = (step / TOTAL_STEPS_INCLUDING_CHANGE) * 100; + + const step1Valid = state !== ''; + const step3Valid = + age !== '' && + parseInt(age, 10) >= 18 && + (!married || (spouseAge !== '' && parseInt(spouseAge, 10) >= 18)); + const step4Valid = income !== ''; + + return ( +
+
+
+ + Step {step} of {TOTAL_STEPS_INCLUDING_CHANGE} + +
+
+
+
+
+ +
+ {step === 1 && ( +
{ e.preventDefault(); if (step1Valid) goNext(); }}> +

Where do you live?

+

+ State and tax year determine which programs and rates apply. +

+
+
+ + +
+
+ + +
+
+
+ +
+
+ )} + + {step === 2 && ( +
+

How do you file your taxes?

+

+ Head of household applies to single filers supporting a child or dependent. +

+
+ {FILING_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+
+ )} + + {step === 3 && ( +
{ e.preventDefault(); if (step3Valid) goNext(); }}> +

How old are you?

+

+

+
+ + setAge(e.target.value)} + onBlur={() => { + const parsed = parseInt(age, 10); + if (!isNaN(parsed)) setAge(String(Math.min(100, Math.max(18, parsed)))); + }} + className="input-field text-lg" + autoFocus + /> +
+ {married && ( +
+ + setSpouseAge(e.target.value)} + onBlur={() => { + const parsed = parseInt(spouseAge, 10); + if (!isNaN(parsed)) setSpouseAge(String(Math.min(100, Math.max(18, parsed)))); + }} + className="input-field text-lg" + /> +
+ )} +
+
+ + +
+
+ )} + + {step === 4 && ( +
{ e.preventDefault(); if (step4Valid) goNext(); }}> +

+ {married ? 'What do you each earn per year?' : 'What do you earn per year?'} +

+

+ Before taxes: wages, self-employment, and other earned income. +

+
+
+ + +
+ {married && ( +
+ + +
+ )} +
+
+ + +
+
+ )} + + {step === 5 && !married && ( +
+

+ Do you have health insurance through an employer? +

+

+

+ + +
+
+ +
+
+ )} + + {step === 5 && married && ( +
{ e.preventDefault(); selectESI(hasESI, spouseHasESI); }}> +

+ Does anyone have health insurance through an employer? +

+

Check all that apply.

+
+ + +
+
+ + +
+
+ )} + + {step === 6 && hasKids !== 'yes' && ( +
+

+ Do you have any children under 18? +

+

+

+ + +
+
+ +
+
+ )} + + {step === 6 && hasKids === 'yes' && ( +
{ + e.preventDefault(); + if (childAges.length > 0) handleComplete(childAges); + }} + > +

How old are your children?

+

+

+ {childAges.map((childAge, index) => ( +
+ + Child {index + 1} + + updateChildAge(index, e.target.value)} + onBlur={() => { if (childAge === '') updateChildAge(index, '0'); }} + className="input-field flex-1 py-2" + /> + +
+ ))} + +
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/LifeEventSelector.tsx b/frontend/src/components/LifeEventSelector.tsx deleted file mode 100644 index 3d1696c..0000000 --- a/frontend/src/components/LifeEventSelector.tsx +++ /dev/null @@ -1,392 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { LifeEvent, LifeEventType, LIFE_EVENTS, US_STATES, Household } from '@/types'; - -interface LifeEventSelectorProps { - selectedEvent: LifeEventType | null; - onSelect: (event: LifeEventType) => void; - eventParams: Record; - onParamsChange: (params: Record) => void; - household: Household; - disabled?: boolean; -} - -export default function LifeEventSelector({ - selectedEvent, - onSelect, - eventParams, - onParamsChange, - household, - disabled = false, -}: LifeEventSelectorProps) { - // Track which field is being edited (to allow empty during typing) - const [editingField, setEditingField] = useState(null); - const [editingValue, setEditingValue] = useState(''); - - // Track spouse child age editing separately - const [editingChildIndex, setEditingChildIndex] = useState(null); - const [editingChildValue, setEditingChildValue] = useState(''); - - // Format number with commas - const formatWithCommas = (value: number): string => { - return value.toLocaleString('en-US'); - }; - - // Handle number input - allow empty during typing - const handleParamChange = (key: string, value: string) => { - setEditingValue(value); - const parsed = parseInt(value.replace(/,/g, '')); - if (!isNaN(parsed)) { - onParamsChange({ ...eventParams, [key]: parsed }); - } - }; - - const handleParamFocus = (key: string, currentValue: number) => { - setEditingField(key); - setEditingValue(String(currentValue)); - }; - - // Validate and set default on blur - const handleParamBlur = (key: string, defaultVal: number, min = 0, max?: number) => { - const parsed = parseInt(editingValue.replace(/,/g, '')); - if (isNaN(parsed) || editingValue === '') { - onParamsChange({ ...eventParams, [key]: defaultVal }); - } else { - const clamped = max !== undefined ? Math.min(max, Math.max(min, parsed)) : Math.max(min, parsed); - onParamsChange({ ...eventParams, [key]: clamped }); - } - setEditingField(null); - setEditingValue(''); - }; - - const getInputValue = (key: string, fallback: number) => { - return editingField === key ? editingValue : (eventParams[key] as number) ?? fallback; - }; - - // Get formatted income value with commas - const getIncomeInputValue = (key: string, fallback: number) => { - if (editingField === key) { - return editingValue; - } - const value = (eventParams[key] as number) ?? fallback; - return formatWithCommas(value); - }; - - const renderEventParams = (event: LifeEvent) => { - if (!selectedEvent || selectedEvent !== event.type) return null; - - switch (event.type) { - case 'having_baby': - return ( -
- - -
- ); - - case 'getting_married': - return ( -
-

Future Spouse Details

- -
-
- - handleParamChange('spouseAge', e.target.value)} - onFocus={() => handleParamFocus('spouseAge', (eventParams.spouseAge as number) || 30)} - onBlur={() => handleParamBlur('spouseAge', 30, 18, 100)} - disabled={disabled} - className="input-field" - /> -
- -
- -
- $ - handleParamChange('spouseIncome', e.target.value)} - onFocus={() => handleParamFocus('spouseIncome', (eventParams.spouseIncome as number) || 0)} - onBlur={() => handleParamBlur('spouseIncome', 0)} - disabled={disabled} - className="currency-field" - /> -
-
-
- -
-
- - -
- {((eventParams.spouseChildAges as number[])?.length || 0) === 0 ? ( -

No children from prior relationship

- ) : ( -
- {((eventParams.spouseChildAges as number[]) || []).map((age, index) => ( -
- Child {index + 1} - { - setEditingChildValue(e.target.value); - const val = parseInt(e.target.value); - if (!isNaN(val)) { - const newAges = [...((eventParams.spouseChildAges as number[]) || [])]; - newAges[index] = Math.min(17, Math.max(0, val)); - onParamsChange({ ...eventParams, spouseChildAges: newAges }); - } - }} - onFocus={() => { - setEditingChildIndex(index); - setEditingChildValue(String(age)); - }} - onBlur={() => { - const parsed = parseInt(editingChildValue); - const newAges = [...((eventParams.spouseChildAges as number[]) || [])]; - if (isNaN(parsed) || editingChildValue === '') { - newAges[index] = 0; - } else { - newAges[index] = Math.min(17, Math.max(0, parsed)); - } - onParamsChange({ ...eventParams, spouseChildAges: newAges }); - setEditingChildIndex(null); - setEditingChildValue(''); - }} - disabled={disabled} - className="input-field flex-1 !py-1.5 text-sm" - /> - yrs - -
- ))} -
- )} -
- - -
- ); - - case 'divorce': - return ( -
- {household.childAges.length > 0 && ( -
- - handleParamChange('childrenKeeping', e.target.value)} - onFocus={() => handleParamFocus('childrenKeeping', (eventParams.childrenKeeping as number) ?? household.childAges.length)} - onBlur={() => handleParamBlur('childrenKeeping', household.childAges.length, 0, household.childAges.length)} - disabled={disabled} - className="input-field" - /> -

- Out of {household.childAges.length} children -

-
- )} -
- ); - - case 'moving_states': - return ( -
- - -
- ); - - case 'changing_income': { - const isMarried = household.filingStatus === 'married_jointly' || household.filingStatus === 'married_separately'; - return ( -
-
- -
- $ - handleParamChange('newIncome', e.target.value)} - onFocus={() => handleParamFocus('newIncome', (eventParams.newIncome as number) ?? household.income)} - onBlur={() => handleParamBlur('newIncome', household.income)} - disabled={disabled} - className="currency-field" - /> -
-

- Current: ${household.income.toLocaleString()} -

-
- - {isMarried && ( -
- -
- $ - handleParamChange('newSpouseIncome', e.target.value)} - onFocus={() => handleParamFocus('newSpouseIncome', (eventParams.newSpouseIncome as number) ?? household.spouseIncome)} - onBlur={() => handleParamBlur('newSpouseIncome', household.spouseIncome)} - disabled={disabled} - className="currency-field" - /> -
-

- Current: ${household.spouseIncome.toLocaleString()} -

-
- )} -
- ); - } - - case 'losing_esi': - // No additional params needed - just simulates losing health insurance - return null; - - default: - return null; - } - }; - - return ( -
-

- Select a Life Event -

- -
- {LIFE_EVENTS.map((event) => { - const isSelected = selectedEvent === event.type; - - return ( -
- - {renderEventParams(event)} -
- ); - })} -
-
- ); -} diff --git a/frontend/src/components/ResultsView.tsx b/frontend/src/components/ResultsView.tsx index 3b7b0a3..ebce3e0 100644 --- a/frontend/src/components/ResultsView.tsx +++ b/frontend/src/components/ResultsView.tsx @@ -18,6 +18,7 @@ import { interface ResultsViewProps { result: SimulationResult; household: Household; + onTryAnother: () => void; onReset: () => void; } @@ -129,27 +130,12 @@ function HealthcareCoverageCard({ if (!hasCoverage) return null; - const getCoverageIcon = (type: string) => { - switch (type) { - case 'ESI': - return 'πŸ’Ό'; - case 'Medicaid': - return 'πŸ₯'; - case 'CHIP': - return 'πŸ‘Ά'; - case 'Marketplace': - return 'πŸ›’'; - default: - return '❀️'; - } - }; - const getCoverageLabel = (type: string) => { switch (type) { case 'ESI': - return 'Employer Insurance'; + return 'Employer insurance'; case 'Marketplace': - return 'ACA Marketplace'; + return 'ACA marketplace'; default: return type; } @@ -163,7 +149,7 @@ function HealthcareCoverageCard({
-

Healthcare Coverage After Event

+

Healthcare coverage after the change

@@ -178,7 +164,7 @@ function HealthcareCoverageCard({ return (
- {getCoverageIcon(type)} +
{getCoverageLabel(type)} Β· @@ -216,7 +202,7 @@ function ComparisonChart({ metrics }: { metrics: BenefitMetric[] }) { return (

- Before vs After + Before vs after

No data to display

@@ -232,7 +218,7 @@ function ComparisonChart({ metrics }: { metrics: BenefitMetric[] }) { return (

- Before vs After + Before vs after

@@ -307,7 +293,7 @@ function ChangeBreakdown({ metrics }: { metrics: BenefitMetric[] }) { return (

- What Changed + What changed

No changes detected

@@ -317,7 +303,7 @@ function ChangeBreakdown({ metrics }: { metrics: BenefitMetric[] }) { return (

- What Changed + What changed

@@ -488,7 +474,7 @@ function DetailedBreakdown({ metrics }: { metrics: BenefitMetric[]; showAll: boo return (

- Detailed Breakdown + Detailed breakdown

{nonEmptyCategories.map(([key, category]) => ( @@ -542,7 +528,7 @@ function DetailedBreakdown({ metrics }: { metrics: BenefitMetric[]; showAll: boo ); } -export default function ResultsView({ result, household, onReset }: ResultsViewProps) { +export default function ResultsView({ result, household, onTryAnother, onReset }: ResultsViewProps) { const [showAllBenefits, setShowAllBenefits] = useState(false); const [showCliffChart, setShowCliffChart] = useState(false); @@ -568,8 +554,8 @@ export default function ResultsView({ result, household, onReset }: ResultsViewP

Something went wrong

The simulation returned incomplete data. Please try again.

-
); @@ -580,7 +566,7 @@ export default function ResultsView({ result, household, onReset }: ResultsViewP {/* Summary Cards */}
- Key Changes + Key changes
@@ -670,12 +656,12 @@ export default function ResultsView({ result, household, onReset }: ResultsViewP
+
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8656c6f..63c3744 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -23,10 +23,15 @@ export interface LifeEvent { type: LifeEventType; label: string; description: string; - icon: string; params?: Record; } +// For tracking selected events with their parameters +export interface SelectedEvent { + type: LifeEventType; + params: Record; +} + export interface BenefitMetric { name: string; label: string; @@ -73,39 +78,33 @@ export interface SimulationResult { export const LIFE_EVENTS: LifeEvent[] = [ { type: 'having_baby', - label: 'Having a Baby', + label: 'Having a baby', description: 'Add a new child to your household', - icon: 'πŸ‘Ά', }, { type: 'getting_married', - label: 'Getting Married', + label: 'Getting married', description: 'Combine households with a spouse', - icon: 'πŸ’', }, { type: 'divorce', - label: 'Getting Divorced', + label: 'Getting divorced', description: 'Separate from your spouse', - icon: 'πŸ’”', }, { type: 'moving_states', - label: 'Moving States', + label: 'Moving states', description: 'Relocate to a different state', - icon: '🏠', }, { type: 'changing_income', - label: 'Changing Income', - description: 'Simulate a raise or income change', - icon: 'πŸ’°', + label: 'Changing income', + description: 'A raise, a pay cut, or a job change', }, { type: 'losing_esi', - label: 'Losing Health Insurance', - description: 'Lose employer-sponsored health insurance', - icon: 'πŸ₯', + label: 'Losing health insurance', + description: 'Employer-sponsored coverage ends', }, ]; diff --git a/modal_app.py b/modal_app.py index 0a06e0a..949270e 100644 --- a/modal_app.py +++ b/modal_app.py @@ -35,7 +35,7 @@ def simulate(data: dict) -> dict: import sys sys.path.insert(0, "/root") - from crossroads.compare import compare + from crossroads.compare import compare, compare_multiple from crossroads.events import ( ChildAgingOut, Divorce, @@ -226,12 +226,35 @@ def format_result_for_frontend(result) -> dict: # Run simulation household = create_household_from_request(data.get("household", {})) - event = create_event_from_request( - data.get("lifeEvent", {}).get("type"), - data.get("lifeEvent", {}).get("params", {}), - household, - ) - result = compare(household, event) + + # Support both single event and multiple events + life_events = data.get("lifeEvents", []) + single_event = data.get("lifeEvent") + + if life_events: + # Multiple events mode + events = [] + current_household = household + for evt in life_events: + event = create_event_from_request( + evt.get("type"), + evt.get("params", {}), + current_household, + ) + events.append(event) + current_household = event.apply(current_household) + result = compare_multiple(household, events) + elif single_event: + # Single event mode (backwards compatible) + event = create_event_from_request( + single_event.get("type"), + single_event.get("params", {}), + household, + ) + result = compare(household, event) + else: + raise ValueError("No life event provided") + response = format_result_for_frontend(result) # Cache the result