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
203 changes: 87 additions & 116 deletions app/MarriageApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { useState, useEffect, useRef } from "react";
import InputForm from "./components/InputForm";
import HouseholdSummary from "./components/HouseholdSummary";
import ResultsDisplay from "./components/ResultsDisplay";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@policyengine/ui-kit/primitives";
import { getCategorizedPrograms, getHeatmapData } from "@/lib/api";
import { formatCurrency } from "@/lib/utils";
import { getCountry, COUNTRIES, DEFAULT_COUNTRY, LEGACY_HASH_COUNTRY, DEFAULT_BRMA } from "@/lib/countries";

const BASE_PATH =
Expand Down Expand Up @@ -186,12 +187,11 @@ export default function MarriageApp({ initialCountry = 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 [guidedSetup, setGuidedSetup] = useState(true);
const [editingSection, setEditingSection] = useState(null);
const [editingCountryId, setEditingCountryId] = useState(countryId);
const resultsPanel = useRef(null);
const focusEditor = useRef(false);
const inputPanel = useRef(null);
const editTrigger = useRef(null);
const savedEdit = useRef(false);
const didAutoCalc = useRef(false);
const calculationId = useRef(0);
const activeRequest = useRef(null);
Expand All @@ -208,20 +208,15 @@ export default function MarriageApp({ initialCountry = null }) {
const resolvedCountry = decoded.current?.countryId || hashCountry || initialCountry || DEFAULT_COUNTRY;
setCountryId(resolvedCountry);
setIsEmbedded(window.self !== window.top);
setGuidedSetup(!decoded.current);
setMounted(true);
}, [initialCountry]);

useEffect(() => {
if (loading) resultsPanel.current?.focus();
}, [loading]);

useEffect(() => {
if (!guidedSetup && focusEditor.current) {
inputPanel.current?.querySelector("button, input")?.focus();
focusEditor.current = false;
if (loading) {
resultsPanel.current?.focus({ preventScroll: true });
resultsPanel.current?.closest(".results-workspace")?.scrollIntoView?.({ block: "start" });
}
}, [guidedSetup]);
}, [loading]);

// Fire the tool_engaged conversion event after the user has been on
// the page long enough to count as genuinely engaged. Matches the
Expand Down Expand Up @@ -296,11 +291,10 @@ export default function MarriageApp({ initialCountry = null }) {
setHeatmapLoading(false);
setError(null);
setHeatmapError(null);
setExternalIncomes(null);
}

function updateHash(data) {
const hash = `#${encodeToHash(countryId, data, isEmbedded)}`;
function updateHash(data, nextCountryId = countryId) {
const hash = `#${encodeToHash(nextCountryId, data, isEmbedded)}`;
window.history.replaceState(null, "", hash);
if (window.self !== window.top) {
window.parent.postMessage({ type: "hashchange", hash }, "*");
Expand Down Expand Up @@ -368,15 +362,15 @@ export default function MarriageApp({ initialCountry = null }) {
}
}

async function handleCalculate(data) {
setGuidedSetup(false);
async function handleCalculate(data, nextCountryId = countryId) {
// 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) };
const snapshot = { countryId: nextCountryId, data: structuredClone(data) };
retrySnapshot.current = snapshot;
const { requestId, controller } = startRequest();
setCountryId(nextCountryId);
setFormData(snapshot.data);
updateHash(snapshot.data);
updateHash(snapshot.data, nextCountryId);

setLoading(true);
setError(null);
Expand All @@ -402,7 +396,7 @@ export default function MarriageApp({ initialCountry = null }) {
}

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

function retryHeatmap() {
Expand All @@ -412,18 +406,30 @@ export default function MarriageApp({ initialCountry = null }) {
}

function handleCellClick(headIncome, spouseIncome) {
setExternalIncomes({ headIncome, spouseIncome });
const data = { ...formData, headIncome, spouseIncome };
setFormData(data);
updateHash(data);
}

function openEdit(section, event) {
editTrigger.current = event.currentTarget;
savedEdit.current = false;
setEditingCountryId(countryId);
setEditingSection(section);
}

function saveEdit(data) {
savedEdit.current = true;
setEditingSection(null);
handleCalculate(data, editingCountryId);
}

// Auto-calculate if URL has params on first load
useEffect(() => {
if (!mounted) return;
if (decoded.current && !didAutoCalc.current) {
didAutoCalc.current = true;
handleCalculate(decoded.current);
handleCalculate(decoded.current, decoded.current.countryId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mounted]);
Expand Down Expand Up @@ -451,109 +457,74 @@ export default function MarriageApp({ initialCountry = null }) {
<p>
{valentine
? "Will tying the knot cost you? Find out this Valentine\u2019s Day."
: <>See how marriage would change your taxes and benefits. <span className="vday-hint">&hearts; Press V</span></>}
: countryId === "uk" ? "See how living together would change your taxes and benefits." : "See how marriage would change your taxes and benefits."}
</p>
</div>
</header>

<div className={`app-layout${guidedSetup ? " app-layout--wizard" : ""}`}>
<aside
className={`app-sidebar ${results ? "has-results" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}
role={guidedSetup ? "region" : undefined}
aria-label={guidedSetup ? "Set up your comparison" : "Your inputs"}
>
{results && !guidedSetup && (
<button
type="button"
className="sidebar-toggle"
onClick={() => setSidebarOpen((v) => !v)}
aria-expanded={sidebarOpen}
aria-controls="comparison-inputs"
>
<span className="sidebar-toggle-summary">
{formData?.regionCode || formData?.stateCode} &middot; {formatCurrency(formData?.headIncome ?? 0, false, country.currencySymbol)} &amp; {formatCurrency(formData?.spouseIncome ?? 0, false, country.currencySymbol)}
</span>
<span className="sidebar-toggle-arrow">{sidebarOpen ? "\u25B2" : "\u25BC"}</span>
</button>
)}
<div className="sidebar-collapsible" id="comparison-inputs">
{!guidedSetup && (
<div className="editor-heading">
<h2>Your inputs</h2>
<button type="button" onClick={() => { setGuidedSetup(true); setSidebarOpen(true); }}>
Guided setup
</button>
</div>
)}
<div className="input-form-container" ref={inputPanel}>
<InputForm
key={mounted ? "ready" : "initial"}
mode={guidedSetup ? "wizard" : "editor"}
onExitWizard={() => { focusEditor.current = true; setGuidedSetup(false); }}
country={country}
countries={isEmbedded ? null : COUNTRIES}
countryId={countryId}
onCountryChange={handleCountryChange}
onCalculate={(data) => { setSidebarOpen(false); handleCalculate(data); }}
onInputChange={clearResults}
loading={loading}
initialValues={decoded.current}
externalIncomes={externalIncomes}
/>
</div>
</div>
</aside>

<section className="app-main" hidden={guidedSetup} ref={resultsPanel} tabIndex={-1} aria-label="Comparison results">
{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" role="status">
<span className="spinner" /> Calculating...
</div>
)}

{!results && !loading && !error && (
<div className="main-placeholder main-placeholder--intro">
<div className="intro-card">
<h2>Calculate your comparison</h2>
<p>
Review your inputs and select Calculate to compare financial
resources, taxes, benefits, and service values.
</p>
<ul className="intro-highlights">
<li>A breakdown by program</li>
<li>Healthcare and early education shown separately</li>
<li>An income grid to explore other earnings</li>
</ul>
<p className="intro-cta">Press <strong>Calculate</strong> to begin.</p>
</div>
</div>
)}

{results && (
<ResultsDisplay
{!mounted ? <div className="journey-loading" role="status">Loading your comparison...</div> : !formData ? (
<section className="setup-workspace" aria-label="Set up your comparison">
<InputForm
country={country}
countries={isEmbedded ? null : COUNTRIES}
countryId={countryId}
onCountryChange={handleCountryChange}
onCalculate={handleCalculate}
loading={loading}
initialValues={decoded.current}
/>
</section>
) : (
<div className="results-workspace">
<HouseholdSummary country={country} data={formData} onEdit={openEdit} />
<section className="results-workspace-content" ref={resultsPanel} tabIndex={-1} aria-label="Comparison results">
{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="journey-loading" role="status"><span className="spinner" /> Calculating...</div>}
{results && <ResultsDisplay
results={results}
heatmapData={heatmapData}
heatmapLoading={heatmapLoading}
heatmapError={heatmapError}
onRetryHeatmap={retryHeatmap}
headIncome={formData?.headIncome ?? 0}
spouseIncome={formData?.spouseIncome ?? 0}
headIncome={formData.headIncome ?? 0}
spouseIncome={formData.spouseIncome ?? 0}
valentine={valentine}
onCellClick={handleCellClick}
esiStatus={formData?.esiStatus}
esiStatus={formData.esiStatus}
country={country}
livingArrangement={formData?.livingArrangement}
hasChildren={Boolean(formData?.children?.length)}
/>
)}
</section>
</div>
livingArrangement={formData.livingArrangement}
hasChildren={Boolean(formData.children?.length)}
/>}
</section>
</div>
)}

<Dialog open={Boolean(editingSection)} onOpenChange={(open) => { if (!open) setEditingSection(null); }}>
<DialogContent className="household-edit-dialog" onCloseAutoFocus={(event) => {
event.preventDefault();
if (savedEdit.current) resultsPanel.current?.focus({ preventScroll: true });
else editTrigger.current?.focus();
}}>
<DialogTitle className="household-edit-title">Edit your household</DialogTitle>
<DialogDescription className="household-edit-description">Save to update your comparison, or cancel to keep your current result.</DialogDescription>
{editingSection && <InputForm
key={editingSection}
section={editingSection}
country={getCountry(editingCountryId)}
countries={isEmbedded ? null : COUNTRIES}
countryId={editingCountryId}
onCountryChange={setEditingCountryId}
initialValues={formData}
onCalculate={saveEdit}
onCancel={() => setEditingSection(null)}
loading={false}
/>}
</DialogContent>
</Dialog>

<footer className="app-footer">
<span>
Expand Down
60 changes: 60 additions & 0 deletions app/components/HouseholdSummary.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { getInputSections } from "@/lib/inputSections";
import { formatCurrency, formatYearLabel } from "@/lib/utils";

const EDIT_LABELS = {
circumstances: "health and disability", work: "work hours", childcare: "childcare",
housing: "housing", finances: "other income and savings",
};

export default function HouseholdSummary({ country, data, onEdit }) {
const money = value => formatCurrency(Number(value) || 0, false, country.currencySymbol);
const region = data.regionCode || data.stateCode;
const regionName = country.regions.find(item => item.code === region)?.name || region;
const children = data.children || [];
const living = country.id === "uk" ? "Together vs apart" : data.livingArrangement === "cohabiting" ? "Living together" : "Living separately";
const childSummary = children.length ? `${children.length} ${children.length === 1 ? "child" : "children"}, ${children.length === 1 ? "age" : "ages"} ${children.map(child => child.age).join(", ")}` : "No children";
const circumstances = [];
for (const [key, name] of [["head", "You"], ["spouse", "Partner"]]) {
if (data.disabilityStatus?.[key]) circumstances.push(`${name}: ${country.id === "us" ? "SSI disability" : "disabled"}`);
if (data.pregnancyStatus?.[key] && country.hasPregnancy) circumstances.push(`${name}: pregnant`);
if (data.esiStatus?.[key] && country.hasESI) circumstances.push(`${name}: employer health insurance`);
if (data.carerStatus?.[key] && country.hasCarer) circumstances.push(`${name}: carer`);
}
children.forEach((child, index) => {
if (child.isDisabled) circumstances.push(`Child ${index + 1}: ${country.id === "us" ? "SSI disability" : "disabled"}`);
});
const careCost = country.id === "us" ? children.reduce((total, child) => total + (Number(child.childcareCost) || 0), 0) : data.childcareCosts;
const tenure = {
OWNED_OUTRIGHT: "Owned outright", OWNED_WITH_MORTGAGE: "Owned with a mortgage",
RENT_PRIVATELY: "Private rent", RENT_FROM_COUNCIL: "Council rent", RENT_FROM_HA: "Housing association rent",
}[data.tenureType || "OWNED_OUTRIGHT"];
const total = values => (Number(values?.head) || 0) + (Number(values?.spouse) || 0);
const descriptions = {
circumstances: circumstances.join(" · ") || "None selected",
work: `You: ${data.childcareWorkHours?.head ?? 40} hours/week · Partner: ${data.childcareWorkHours?.spouse ?? 40} hours/week`,
childcare: `${money(careCost)} a year${country.id === "us" && careCost > 0 ? ` · CCDF slot ${data.ccdfSlotAvailable === false ? "unavailable" : "available"}` : ""}${children.some(child => child.isHeadStartEnrolled) ? " · Head Start enrolled" : ""}`,
housing: `${tenure}${data.tenureType?.startsWith("RENT_") ? ` · ${money(data.rent)} a year per home` : ""}`,
finances: `${money(total(data.selfEmploymentIncome))} self-employment · ${money(total(data.pensionIncome))} pensions · ${money(data.savings)} savings`,
};
const editButton = (id, label) => <button type="button" className="household-edit-link" aria-label={`Edit ${label}`} onClick={event => onEdit(id, event)}>Edit</button>;

return <section className="household-summary" aria-label="Your household">
<div className="household-summary-primary">
<div className="household-summary-item">
<div className="household-summary-heading"><h2>Comparison</h2>{editButton("comparison", "comparison")}</div>
<p>{regionName} · {formatYearLabel(data.year || country.defaultYear, country.id)} · {living}</p>
</div>
<div className="household-summary-item">
<div className="household-summary-heading"><h2>Household</h2>{editButton("household", "household")}</div>
<p>You: {money(data.headIncome)} · Partner: {money(data.spouseIncome)} a year</p>
<p className="household-summary-secondary">Ages {data.headAge ?? country.defaultAge} and {data.spouseAge ?? country.defaultAge} · {childSummary}</p>
</div>
</div>
<details className="household-summary-details">
<summary>Other household details</summary>
<dl>{getInputSections(country, data).filter(item => EDIT_LABELS[item.id]).map(item => <div className="household-detail-row" key={item.id}>
<dt>{item.label}</dt><dd>{descriptions[item.id]}</dd>{editButton(item.id, EDIT_LABELS[item.id])}
</div>)}</dl>
</details>
</section>;
}
22 changes: 22 additions & 0 deletions app/components/ImpactDrivers.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from "react";
import { financialChangeDrivers } from "@/lib/resultDrivers";
import { formatCurrency } from "@/lib/utils";

export default function ImpactDrivers({ results, countryId, currencySymbol }) {
const drivers = financialChangeDrivers(results, countryId);
if (drivers.length === 0) return null;

return <section className="impact-drivers" aria-label="What changes">
<h3>What changes</h3>
<p>Largest changes in annual {countryId === "us" ? "financial resources" : "net income"}.</p>
<ul className="impact-drivers-list">
{drivers.map(driver => <li className="impact-driver" key={driver.key}>
<span className="impact-driver-label">{driver.label}</span>
<strong className={`impact-driver-value ${driver.effect > 0 ? "positive" : "negative"}`}>
{formatCurrency(driver.effect, true, currencySymbol)}
</strong>
<span className="impact-driver-description">{driver.description}</span>
</li>)}
</ul>
</section>;
}
Loading
Loading