Skip to content
Merged
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
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Calculator checks

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: calculator-checks-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
checks:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
PYTHONDONTWRITEBYTECODE: "1"
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.12"
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
enable-cache: true
cache-dependency-glob: backend/uv.lock
- name: Install locked dependencies
run: |
bun install --frozen-lockfile
uv sync --project backend --frozen --python 3.12
- name: Check backend formatting and lint
run: |
uv run --project backend --no-sync ruff check backend
uv run --project backend --no-sync ruff format --check backend
- name: Backend tests
run: bun run test:backend
- name: Frontend lint
run: bun run lint
- name: Unit and local US integration tests
run: bun run test:ci
- name: Production build
run: bun run build
32 changes: 32 additions & 0 deletions .github/workflows/live-uk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Live UK integration checks

on:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: live-uk-${{ github.ref }}
cancel-in-progress: true

jobs:
live-uk:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.12"
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
enable-cache: true
cache-dependency-glob: backend/uv.lock
- name: Install locked dependencies
run: |
bun install --frozen-lockfile
uv sync --project backend --frozen --python 3.12
- name: External UK integration checks
run: bun run test:live:uk
34 changes: 26 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ forwarded to the server component; see `app/page.jsx`.
## Development

```sh
bun install
bun install --frozen-lockfile
uv sync --project backend --frozen --python 3.12
NEXT_PUBLIC_BASE_PATH="" bun run dev # serve at http://localhost:5173/
bun run test:ci # component and live API tests
bun run test:ci # unit and local US integration tests
bun run test:backend # accounting and HTTP tests
bun run build # production bundle
```

Expand All @@ -29,9 +31,10 @@ server serves at the root — matches the Oregon Kicker convention.
## US childcare, early education and disability

US calculations use the app-owned [household backend](backend/README.md), pinned
to PolicyEngine US 1.824.1. UK calculations use the public v1 API. Set
`NEXT_PUBLIC_US_API_URL=http://127.0.0.1:8012` to use the local US backend for
development or tests. Production defaults to the deployed Modal service and
to PolicyEngine US 1.824.1. UK policy calculations still use the public v1 API,
through the same backend, which applies the calculator’s rent accounting. Set
`NEXT_PUBLIC_US_API_URL=http://127.0.0.1:8012` to use a local backend for both
countries in development. Production defaults to the deployed Modal service and
rejects responses whose model version differs from the committed metadata.

Paid childcare costs feed into the estimates automatically. The funded-slot
Expand All @@ -53,9 +56,11 @@ Initial-applicant, standard-quality,
zero-assets and available-funding assumptions apply unless an input says otherwise.

Head Start and Early Head Start eligibility are shown separately from their
estimated service values. Those noncash values are excluded from net income
unless explicitly selected; they use state spending per enrollee and assume
eligible participation. Actual Head Start enrollment is a separate childcare
estimated service values. Healthcare and early education values are always
separate from household financial resources and added only in the explicitly
labeled combined-resources comparison. Head Start values use state spending
per enrollee and assume eligible participation. Employer insurance affects
eligibility inputs, independently of this presentation. Actual Head Start enrollment is a separate childcare
input. The SSI medical-disability checkbox sets both
`meets_ssi_disability_criteria` and `is_disabled`; financial eligibility remains
model-computed.
Expand All @@ -68,3 +73,16 @@ provider enums and aggregate composition after reviewing a model update:
uv run --project backend python -m backend.simulation --metadata /tmp/marriage-metadata.json
US_METADATA_FILE=/tmp/marriage-metadata.json bun run metadata
```

## Accounting and release checks

The backend returns versioned, reconciled annual series for every household
and income-grid point. The browser displays these series without adjusting
childcare benefits, service values, or rent again. Financial resources include
benefits such as SNAP; they are not a cash-only measure. The original model
outputs remain available separately in the API response.

The [test workflow](docs/testing.md) checks the pinned runtime and frontend on
every pull request. The UK model’s external integration checks run separately.
Requests time out after two minutes and are cancelled when inputs change.
Failed charts retain the household comparison and offer a chart-only retry.
115 changes: 84 additions & 31 deletions app/MarriageApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,18 @@ export default function MarriageApp({ initialCountry = null }) {
const [loading, setLoading] = useState(false);
const [heatmapLoading, setHeatmapLoading] = useState(false);
const [error, setError] = useState(null);
const [heatmapError, setHeatmapError] = useState(null);
const [formData, setFormData] = useState(null);
const [valentine, setValentine] = useState(false);
const [showConfetti, setShowConfetti] = useState(false);
const [externalIncomes, setExternalIncomes] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const didAutoCalc = useRef(false);
const calculationId = useRef(0);
const activeRequest = useRef(null);
const retrySnapshot = useRef(null);

useEffect(() => () => activeRequest.current?.abort(), []);

// Resolve browser-only state after mount.
// initialCountry already seeded countryId (for the rewrite path), so it
Expand Down Expand Up @@ -266,11 +271,15 @@ export default function MarriageApp({ initialCountry = null }) {
// A request from the previous living arrangement must not repopulate the
// page after an input has changed.
calculationId.current += 1;
activeRequest.current?.abort();
activeRequest.current = null;
retrySnapshot.current = null;
setResults(null);
setHeatmapData(null);
setLoading(false);
setHeatmapLoading(false);
setError(null);
setHeatmapError(null);
setExternalIncomes(null);
}

Expand All @@ -282,16 +291,14 @@ export default function MarriageApp({ initialCountry = null }) {
}
}

async function handleCalculate(data) {
const requestId = ++calculationId.current;
setFormData(data);
updateHash(data);

function requestArguments(snapshot, signal) {
const { data, countryId: requestCountry } = snapshot;
const {
headIncome, spouseIncome, headAge, spouseAge,
children, disabilityStatus, pregnancyStatus, esiStatus, year,
} = data;
const extras = {
signal,
livingArrangement: data.livingArrangement || "cohabiting",
rent: data.rent || 0,
tenureType: data.tenureType || "OWNED_OUTRIGHT",
Expand All @@ -308,45 +315,85 @@ export default function MarriageApp({ initialCountry = null }) {
childcareActivityEligible: (data.regionCode || data.stateCode) === "NV" && Boolean(data.childcareActivityEligible),
};
const regionCode = data.regionCode || data.stateCode;
const effectiveRegion = countryId === "us" && regionCode === "NYC" ? "NY" : regionCode;
const inNYC = countryId === "us" && regionCode === "NYC";
const effectiveRegion = requestCountry === "us" && regionCode === "NYC" ? "NY" : regionCode;
const inNYC = requestCountry === "us" && regionCode === "NYC";
return {
scalar: [requestCountry, effectiveRegion, headIncome, spouseIncome, children,
disabilityStatus, year, pregnancyStatus, headAge, spouseAge, esiStatus, inNYC, extras],
heatmap: [requestCountry, effectiveRegion, children, disabilityStatus, year,
pregnancyStatus, headIncome, spouseIncome, headAge, spouseAge, esiStatus, inNYC, extras],
};
}

function startRequest() {
activeRequest.current?.abort();
const controller = new AbortController();
activeRequest.current = controller;
return { requestId: ++calculationId.current, controller };
}

async function loadHeatmap(snapshot, requestId, controller) {
setHeatmapLoading(true);
setHeatmapError(null);
try {
const heatmap = await getHeatmapData(...requestArguments(snapshot, controller.signal).heatmap);
if (requestId !== calculationId.current || controller.signal.aborted) return;
setHeatmapData(heatmap);
} catch (e) {
if (requestId !== calculationId.current || controller.signal.aborted) return;
setHeatmapError(e.message || "The heatmap could not be calculated. Please try again.");
// A failed comparison must also stop any sibling fetches still running.
controller.abort();
} finally {
if (requestId === calculationId.current) {
setHeatmapLoading(false);
activeRequest.current = null;
}
}
}

async function handleCalculate(data) {
// Retry the submitted values, never a partially edited form or mutable
// object retained by the caller. Country changes clear this snapshot.
const snapshot = { countryId, data: structuredClone(data) };
retrySnapshot.current = snapshot;
const { requestId, controller } = startRequest();
setFormData(snapshot.data);
updateHash(snapshot.data);

setLoading(true);
setError(null);
setHeatmapError(null);
setHeatmapLoading(false);
setResults(null);
setHeatmapData(null);

try {
const result = await getCategorizedPrograms(
countryId, effectiveRegion, headIncome, spouseIncome, children,
disabilityStatus, year, pregnancyStatus, headAge, spouseAge,
esiStatus, inNYC, extras,
);
if (requestId !== calculationId.current) return;
const result = await getCategorizedPrograms(...requestArguments(snapshot, controller.signal).scalar);
if (requestId !== calculationId.current || controller.signal.aborted) return;
setResults(result);
setLoading(false);

setHeatmapLoading(true);
try {
const heatmap = await getHeatmapData(
countryId, effectiveRegion, children, disabilityStatus, year,
pregnancyStatus, headIncome, spouseIncome, headAge, spouseAge,
esiStatus, inNYC, extras,
);
if (requestId !== calculationId.current) return;
setHeatmapData(heatmap);
} catch (e) {
console.error("Heatmap error:", e);
} finally {
if (requestId === calculationId.current) setHeatmapLoading(false);
}
await loadHeatmap(snapshot, requestId, controller);
} catch (e) {
if (requestId !== calculationId.current) return;
setError(e.message);
if (requestId !== calculationId.current || controller.signal.aborted) return;
setError(e.message || "The calculation could not be completed. Please try again.");
setLoading(false);
controller.abort();
activeRequest.current = null;
}
}

function retryCalculation() {
if (retrySnapshot.current) handleCalculate(retrySnapshot.current.data);
}

function retryHeatmap() {
if (!retrySnapshot.current || !results) return;
const { requestId, controller } = startRequest();
loadHeatmap(retrySnapshot.current, requestId, controller);
}

function handleCellClick(headIncome, spouseIncome) {
setExternalIncomes({ headIncome, spouseIncome });
const data = { ...formData, headIncome, spouseIncome };
Expand Down Expand Up @@ -423,15 +470,19 @@ export default function MarriageApp({ initialCountry = null }) {
</aside>

<main className="app-main">
{error && <div className="error">{error}</div>}
{error && <div className="error" role="alert">
<p>{error}</p>
<button type="button" className="mt-3 rounded-md bg-primary px-4 py-2 font-medium text-white"
onClick={retryCalculation}>Retry calculation</button>
</div>}

{loading && (
<div className="main-placeholder">
<span className="spinner" /> Calculating...
</div>
)}

{!results && !loading && (
{!results && !loading && !error && (
<div className="main-placeholder main-placeholder--intro">
<div className="intro-card">
<h2>What would marriage mean for your taxes?</h2>
Expand All @@ -456,6 +507,8 @@ export default function MarriageApp({ initialCountry = null }) {
results={results}
heatmapData={heatmapData}
heatmapLoading={heatmapLoading}
heatmapError={heatmapError}
onRetryHeatmap={retryHeatmap}
headIncome={formData?.headIncome ?? 0}
spouseIncome={formData?.spouseIncome ?? 0}
valentine={valentine}
Expand Down
20 changes: 15 additions & 5 deletions app/components/MetricCards.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { formatCurrency, formatPercent, unmarriedTotal } from "@/lib/utils";
import { formatCurrency, formatPercent, unmarriedTotal, resourceValues } from "@/lib/utils";

function getShareUrl(countryId) {
const hash = window.location.hash;
Expand All @@ -15,9 +15,13 @@ export default function MetricCards({ results, showHealth, currencySymbol, count
const { married } = results;
const [copied, setCopied] = useState(false);

const financialView = countryId === "us";
const netKey = showHealth ? "householdNetIncomeWithHealth" : "householdNetIncome";
const netMarried = married.aggregates[netKey];
const netSeparate = unmarriedTotal(results, "aggregates", netKey);
const netMarried = financialView ? resourceValues(married).financialResources : married.aggregates[netKey];
const netSeparate = financialView
? (results.unmarried ? [results.unmarried] : [results.headSingle, results.spouseSingle])
.reduce((sum, result) => sum + resourceValues(result).financialResources, 0)
: unmarriedTotal(results, "aggregates", netKey);
const delta = netMarried - netSeparate;
const pctChange = netSeparate !== 0 ? delta / netSeparate : 0;
const afterChildcare = married.childcare?.enabled;
Expand Down Expand Up @@ -64,17 +68,22 @@ export default function MetricCards({ results, showHealth, currencySymbol, count
}

return (
<section aria-label={financialView ? "Household financial resources" : "Net income comparison"}>
{financialView && <div className="resource-heading">
<h3>Household financial resources</h3>
<p>Annual resources after taxes and modeled expenses, including benefits such as SNAP.</p>
</div>}
<div className="metric-cards">
<div className="metric-card" data-testid="metric-net">
<div className="metric-label">{unmarriedLabel}</div>
<div className="metric-value">{formatCurrency(netSeparate, false, sym)}</div>
<div className="metric-desc">{results.unmarried ? "Household net income" : "Combined net income"}{afterChildcare ? " after childcare" : ""}</div>
<div className="metric-desc">{financialView ? "Annual financial resources" : <>{results.unmarried ? "Household net income" : "Combined net income"}{afterChildcare ? " after childcare" : ""}</>}</div>
</div>

<div className="metric-card" data-testid="metric-pct">
<div className="metric-label">Married</div>
<div className="metric-value">{formatCurrency(netMarried, false, sym)}</div>
<div className="metric-desc">Household net income{afterChildcare ? " after childcare" : ""}</div>
<div className="metric-desc">{financialView ? "Annual financial resources" : <>Household net income{afterChildcare ? " after childcare" : ""}</>}</div>
</div>

<div className={deltaClass} data-testid="metric-delta">
Expand All @@ -90,5 +99,6 @@ export default function MetricCards({ results, showHealth, currencySymbol, count
<div className="metric-desc">{formatPercent(pctChange, true)}</div>
</div>
</div>
</section>
);
}
Loading
Loading