@@ -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 && (
+
+ )}
+
+ {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 && (
+
+ )}
+
+ {step === 4 && (
+
+ )}
+
+ {step === 5 && !married && (
+
+
+ Do you have health insurance through an employer?
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {step === 5 && married && (
+
+ )}
+
+ {step === 6 && hasKids !== 'yes' && (
+
+
+ Do you have any children under 18?
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {step === 6 && hasKids === 'yes' && (
+
+ )}
+
+
+ );
+}
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({