Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Model how life events affect a household's healthcare coverage and monthly out-of-pocket cost across **Medicaid**, **CHIP**, and the **ACA marketplace**.

Live: https://coverage-compass-policy-engine.vercel.app/
Live: https://coverage-compass.vercel.app/

## What it covers

Expand Down
39 changes: 5 additions & 34 deletions frontend/src/app/api/baseline/route.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { proxyToBackend } from '@/lib/backendProxy';

const BACKEND_URL = process.env.BACKEND_URL;
// Warm-up calls hit the same slow backend as simulate; keep the same budget.
export const maxDuration = 120;

export async function POST(request: NextRequest) {
if (!BACKEND_URL) {
return NextResponse.json(
{ error: 'Backend not configured. Set BACKEND_URL environment variable.' },
{ status: 503 }
);
}

try {
const body = await request.json();

// Modal URLs are the endpoint directly, Cloud Run needs /api/baseline suffix
const url = BACKEND_URL.includes('modal.run')
? BACKEND_URL
: `${BACKEND_URL}/api/baseline`;

const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Backend request failed');
}

const result = await response.json();
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch baseline';
return NextResponse.json({ error: message }, { status: 500 });
}
return proxyToBackend(request, 'baseline', 'Failed to fetch baseline');
}
40 changes: 6 additions & 34 deletions frontend/src/app/api/simulate/route.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { proxyToBackend } from '@/lib/backendProxy';

const BACKEND_URL = process.env.BACKEND_URL;
// PolicyEngine sims + Modal cold starts can take over a minute; without
// this the platform default duration kills the function mid-simulation.
export const maxDuration = 120;

export async function POST(request: NextRequest) {
if (!BACKEND_URL) {
return NextResponse.json(
{ error: 'Backend not configured. Set BACKEND_URL environment variable.' },
{ status: 503 }
);
}

try {
const body = await request.json();

// Modal URLs are the endpoint directly, Cloud Run needs /api/simulate suffix
const url = BACKEND_URL.includes('modal.run')
? BACKEND_URL
: `${BACKEND_URL}/api/simulate`;

const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Backend request failed');
}

const result = await response.json();
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to simulate';
return NextResponse.json({ error: message }, { status: 500 });
}
return proxyToBackend(request, 'simulate', 'Failed to simulate');
}
32 changes: 32 additions & 0 deletions frontend/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

// Route-level error boundary: catches render errors so users get a retry
// button instead of a blank page.
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen bg-[#F1F5F9] flex items-center justify-center px-4">
<div className="bg-white border border-gray-200 rounded-xl p-8 shadow-sm text-center max-w-md">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Something went wrong</h2>
<p className="text-sm text-gray-500 mb-4">
The page hit an unexpected error. Your household details may need to be re-entered.
</p>
{error.digest && (
<p className="text-[11px] text-gray-400 mb-4">Reference: {error.digest}</p>
)}
<button
type="button"
onClick={reset}
className="px-4 py-2 rounded-lg bg-[#319795] text-white text-sm font-medium hover:bg-[#285E61] transition-colors"
>
Try again
</button>
</div>
</div>
);
}
108 changes: 84 additions & 24 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import HouseholdWizard from '@/components/HouseholdWizard';
import ChangeWizard from '@/components/ChangeWizard';
import ResultsView from '@/components/ResultsView';
Expand All @@ -24,7 +24,10 @@ function encodeScenario(household: Household, event: LifeEventType, params: Reco
function decodeScenario(encoded: string): { household: Household; event: LifeEventType; params: Record<string, unknown> } | null {
try {
const data = JSON.parse(atob(encoded));
if (data.h && data.e) {
// Only accept event types the app actually supports — a crafted link
// with an unknown type would otherwise render an "undefined …"
// headline and hit the backend with an unvetted event.
if (data.h && LIFE_EVENTS.some((ev) => ev.type === data.e)) {
const household: Household = {
state: data.h.state || 'CA',
zipCode: data.h.zipCode || undefined,
Expand All @@ -37,6 +40,7 @@ function decodeScenario(encoded: string): { household: Household; event: LifeEve
hasESI: data.h.hasESI ?? false,
spouseHasESI: data.h.spouseHasESI ?? false,
year: data.h.year ?? 2026,
pregnantMember: data.h.pregnantMember ?? null,
};
return { household, event: data.e, params: data.p || {} };
}
Expand All @@ -59,6 +63,11 @@ export default function Home() {
const result = scenarios.find((s) => s.id === currentScenarioId)?.result ?? null;
const otherScenarios = scenarios.filter((s) => s.id !== currentScenarioId);

// Monotonic id for in-flight simulations. A response only lands if its id
// still matches, so a slow request can't resurrect state after the user
// starts over or kicks off a newer simulation.
const requestSeq = useRef(0);

// Fire a no-op /api/baseline request when the wizard completes. This
// doesn't return useful data anymore, but it warms the Modal container
// so the subsequent /api/simulate call on Apply doesn't pay a cold start.
Expand All @@ -79,6 +88,7 @@ export default function Home() {
event: LifeEventType,
params: Record<string, unknown>,
) => {
const reqId = ++requestSeq.current;
setIsLoading(true);
setError(null);
try {
Expand All @@ -90,7 +100,15 @@ export default function Home() {
lifeEvent: { type: event, params },
}),
});
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
// Platform timeouts and gateway errors return HTML/text bodies.
throw new Error(
`The simulation service returned an unexpected response (HTTP ${response.status}). Please try again.`,
);
}
const data = await response.json();
if (reqId !== requestSeq.current) return; // stale response; a newer action superseded it
if (data.error) throw new Error(data.error);
if (!data.before || !data.after) throw new Error('Invalid response from simulation');
const id = newScenarioId();
Expand All @@ -101,13 +119,18 @@ export default function Home() {
setShareUrl(url);
window.history.replaceState({}, '', `?s=${encoded}`);
} catch (err) {
if (reqId !== requestSeq.current) return;
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setIsLoading(false);
if (reqId === requestSeq.current) {
setIsLoading(false);
}
}
}, []);

const restoreScenario = useCallback((s: SavedScenario, h: Household) => {
requestSeq.current++; // an in-flight simulation must not override this restore
setIsLoading(false);
setCurrentScenarioId(s.id);
setSelectedEvent(s.event);
setEventParams(s.params);
Expand Down Expand Up @@ -137,7 +160,9 @@ export default function Home() {
}, [runSimulation]);

const handleWizardComplete = (h: Household) => {
requestSeq.current++; // drop any in-flight simulation from a previous household
setHousehold(h);
setIsLoading(false);
setSelectedEvent(null);
setEventParams({});
setScenarios([]);
Expand Down Expand Up @@ -171,6 +196,8 @@ export default function Home() {
const handleTryAnother = () => {
// Keep household + scenario history. Clear the active event/result so
// the user can model a new what-if.
requestSeq.current++;
setIsLoading(false);
setSelectedEvent(null);
setEventParams({});
setCurrentScenarioId(null);
Expand All @@ -180,6 +207,8 @@ export default function Home() {
};

const handleReset = () => {
requestSeq.current++;
setIsLoading(false);
setHousehold(null);
setSelectedEvent(null);
setEventParams({});
Expand Down Expand Up @@ -301,8 +330,10 @@ export default function Home() {
</>
)}

{/* Household entered: show change wizard until results are ready */}
{household && !isLoading && !result && (
{/* Household entered: show change wizard until results are ready.
Hidden while an error card is up so the two don't contradict
each other — the error card's own buttons route back here. */}
{household && !isLoading && !result && !error && (
<ChangeWizard
household={household}
onApply={(event, params) => {
Expand Down Expand Up @@ -568,22 +599,45 @@ function describeScenarioChanges(
}
}

// Describe a set of people ("You", "Spouse", "Child 1", …) as a phrase.
function describeWho(labels: string[]): string {
const hasYou = labels.includes('You');
const others = labels.filter((l) => l !== 'You');
if (hasYou && others.length === 0) return 'you';
if (hasYou) return 'you and your family';
if (others.length === 1 && others[0] === 'Spouse') return 'your partner';
if (others.every((l) => l.startsWith('Child'))) {
return others.length === 1 ? 'your child' : 'your children';
}
return 'your family';
}

// Build the hero headline strictly from observable transitions in the
// simulation result so the copy can't contradict the table beneath it.
// Per-person transitions take priority: "your family qualifies for
// Medicaid" is the story, not the mechanical side effect that the ACA
// tax credit ended because of it.
function getHeroHeadline(eventType: LifeEventType, result: SimulationResult): string {
const beforePeople = result.healthcareBefore?.people ?? [];
const afterPeople = result.healthcareAfter?.people ?? [];
const head = (label: string) => label === 'You';

// Per-person transitions for the head of household.
const headBefore = beforePeople.find((p) => head(p.label))?.coverage ?? null;
const headAfter = afterPeople.find((p) => head(p.label))?.coverage ?? null;
// Per-person coverage transitions, keyed by label.
const beforeByLabel = new Map(beforePeople.map((p) => [p.label, p.coverage]));
const afterByLabel = new Map(afterPeople.map((p) => [p.label, p.coverage]));
const allLabels = Array.from(new Set([...beforeByLabel.keys(), ...afterByLabel.keys()]));

// Household-level transitions (any person).
const anyMedicaidBefore = beforePeople.some((p) => p.coverage === 'Medicaid');
const anyMedicaidAfter = afterPeople.some((p) => p.coverage === 'Medicaid');
const anyMarketplaceBefore = beforePeople.some((p) => p.coverage === 'Marketplace');
const anyMarketplaceAfter = afterPeople.some((p) => p.coverage === 'Marketplace');
const gainedMedicaid = allLabels.filter(
(l) => beforeByLabel.get(l) !== 'Medicaid' && afterByLabel.get(l) === 'Medicaid',
);
const lostMedicaid = allLabels.filter(
(l) => beforeByLabel.get(l) === 'Medicaid' && afterByLabel.get(l) !== 'Medicaid',
);
const isMarketplace = (c: string | null | undefined) => !!c && c.startsWith('Marketplace');
const anyMarketplaceBefore = beforePeople.some((p) => isMarketplace(p.coverage));
const anyMarketplaceAfter = afterPeople.some((p) => isMarketplace(p.coverage));
const gainedMedicare = allLabels.filter(
(l) => beforeByLabel.get(l) !== 'Medicare' && afterByLabel.get(l) === 'Medicare',
);

// Metric-level transitions.
const metrics = result.before.metrics ?? [];
Expand All @@ -604,23 +658,29 @@ function getHeroHeadline(eventType: LifeEventType, result: SimulationResult): st
};
const subject = verbs[eventType];

// Salient transition takes priority over generic copy.
if (!anyMedicaidBefore && anyMedicaidAfter) return `${subject} qualifies you for Medicaid.`;
if (anyMedicaidBefore && !anyMedicaidAfter) return `${subject} moves you out of Medicaid.`;
if (lostPTC) return `${subject} ends your ACA tax credit.`;
// Coverage-program transitions take priority over tax-credit mechanics.
if (gainedMedicaid.length > 0) {
return `${subject} qualifies ${describeWho(gainedMedicaid)} for Medicaid.`;
}
if (lostMedicaid.length > 0) {
return `${subject} moves ${describeWho(lostMedicaid)} out of Medicaid.`;
}
if (gainedMedicare.length > 0) {
return `${subject} moves ${describeWho(gainedMedicare)} onto Medicare.`;
}
if (lostPTC) return `${subject} ends your ACA tax credit — you'd pay full price.`;
if (gainedPTC) return `${subject} qualifies you for an ACA tax credit.`;
const headBefore = beforeByLabel.get('You') ?? null;
const headAfter = afterByLabel.get('You') ?? null;
if (headBefore === 'ESI' && headAfter !== 'ESI') return `${subject} ends your employer health insurance.`;
if (headBefore !== 'ESI' && headAfter === 'ESI') return `${subject} starts employer health insurance.`;
if (!anyMarketplaceBefore && anyMarketplaceAfter) return `${subject} moves you onto the ACA marketplace.`;
if (anyMarketplaceBefore && !anyMarketplaceAfter) return `${subject} moves you off the ACA marketplace.`;

// Detect a fully-unchanged outcome so we don't promise a "change" that
// didn't happen. Compare before/after person-by-person.
const beforePeopleByLabel = new Map(beforePeople.map((p) => [p.label, p.coverage]));
const afterPeopleByLabel = new Map(afterPeople.map((p) => [p.label, p.coverage]));
const allLabels = new Set([...beforePeopleByLabel.keys(), ...afterPeopleByLabel.keys()]);
const coverageUnchanged = Array.from(allLabels).every(
(label) => beforePeopleByLabel.get(label) === afterPeopleByLabel.get(label),
// didn't happen.
const coverageUnchanged = allLabels.every(
(label) => beforeByLabel.get(label) === afterByLabel.get(label),
);
if (coverageUnchanged) {
return `${subject} doesn't change your coverage.`;
Expand Down
Loading