Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
519 changes: 516 additions & 3 deletions frontend/bun.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
},
"dependencies": {
"@policyengine/ui-kit": "^0.12.0",
"@types/react-plotly.js": "^2.6.4",
"maplibre-gl": "^5.18.0",
"next": "^16.2.6",
"plotly.js-dist-min": "^3.5.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-plotly.js": "^2.6.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
Expand Down
9 changes: 9 additions & 0 deletions frontend/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 69 additions & 28 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ import Hero from "./components/Hero";
import StatsBanner from "./components/StatsBanner";
import ControlBar from "./components/ControlBar";
import Footer from "./components/Footer";
import PillGroup from "./components/PillGroup";
import type { MetricKey, ViewType, SupportedYear } from "./types";

const Visualization = lazy(() => import("./components/Visualization"));
const HouseholdCalculator = lazy(
() => import("./components/HouseholdCalculator"),
);

type AppView = "map" | "household";

const styles: Record<string, CSSProperties> = {
mainContent: {
Expand Down Expand Up @@ -42,6 +48,11 @@ const styles: Record<string, CSSProperties> = {
color: "#dc2626",
fontWeight: 600,
},
tabBar: {
display: "flex",
justifyContent: "center",
marginBottom: 24,
},
};

export default function App() {
Expand All @@ -57,6 +68,7 @@ export default function App() {
const { dataByYear, stateGeoData, districtGeoData, loading, error } =
useData();

const [appView, setAppView] = useState<AppView>("map");
const [year, setYear] = useState<SupportedYear>(2025);
const [viewType, setViewType] = useState<ViewType>("state");
const [creditType, setCreditType] = useState("CTCs and EITCs");
Expand Down Expand Up @@ -187,42 +199,71 @@ export default function App() {
return (
<>
<Hero />
<StatsBanner
totalCost={stats.totalCost}
povertyReduction={stats.povertyReduction}
childPovertyReduction={stats.childPovertyReduction}
/>
<main style={styles.mainContent}>
<ControlBar
year={year}
onYearChange={handleYearChange}
viewType={viewType}
onViewTypeChange={handleViewTypeChange}
creditType={creditType}
onCreditTypeChange={handleCreditTypeChange}
metric={metric}
onMetricChange={setMetric}
{appView === "map" && (
<StatsBanner
totalCost={stats.totalCost}
povertyReduction={stats.povertyReduction}
childPovertyReduction={stats.childPovertyReduction}
/>
{stateGeoData && districtGeoData && (
)}
<main style={styles.mainContent}>
<div style={styles.tabBar}>
<PillGroup
options={[
{ label: "Nationwide impacts", value: "map" },
{ label: "Household calculator", value: "household" },
]}
value={appView}
onChange={(v) => setAppView(v as AppView)}
ariaLabel="View"
/>
</div>
{appView === "map" && (
<>
<ControlBar
year={year}
onYearChange={handleYearChange}
viewType={viewType}
onViewTypeChange={handleViewTypeChange}
creditType={creditType}
onCreditTypeChange={handleCreditTypeChange}
metric={metric}
onMetricChange={setMetric}
/>
{stateGeoData && districtGeoData && (
<Suspense
fallback={
<div style={styles.loading} role="status" aria-live="polite">
<div style={styles.spinner} />
<p>Loading map...</p>
</div>
}
>
<Visualization
stateGeoData={stateGeoData}
districtGeoData={districtGeoData}
filteredData={filteredData as unknown as Record<string, unknown>[]}
viewType={viewType}
metric={metric}
creditType={creditType}
selectedRegion={selectedRegion}
regionData={regionData}
onRegionClick={handleRegionClick}
/>
</Suspense>
)}
</>
)}
{appView === "household" && (
<Suspense
fallback={
<div style={styles.loading} role="status" aria-live="polite">
<div style={styles.spinner} />
<p>Loading map...</p>
<p>Loading calculator...</p>
</div>
}
>
<Visualization
stateGeoData={stateGeoData}
districtGeoData={districtGeoData}
filteredData={filteredData as unknown as Record<string, unknown>[]}
viewType={viewType}
metric={metric}
creditType={creditType}
selectedRegion={selectedRegion}
regionData={regionData}
onRegionClick={handleRegionClick}
/>
<HouseholdCalculator year={year} />
</Suspense>
)}
</main>
Expand Down
182 changes: 182 additions & 0 deletions frontend/src/api/policyengine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
export type FilingStatus = "SINGLE" | "HEAD_OF_HOUSEHOLD" | "JOINT";

export interface HouseholdInput {
state: string;
year: number;
filingStatus: FilingStatus;
primaryAge: number;
spouseAge?: number;
employmentIncome: number;
spouseEmploymentIncome?: number;
childAges: number[];
}

export interface CreditBreakdown {
federalEitc: number;
federalCtc: number;
stateEitc: number;
stateCtc: number;
}

export interface SweepPoint extends CreditBreakdown {
earnings: number;
}

const API_URL = "https://api.policyengine.org/us/calculate";

function buildHousehold(
input: HouseholdInput,
axes?: { min: number; max: number; count: number },
) {
const y = String(input.year);
const isJoint = input.filingStatus === "JOINT";

const people: Record<string, Record<string, Record<string, number | null>>> = {
you: {
age: { [y]: input.primaryAge },
employment_income: {
[y]: axes ? null : input.employmentIncome,
},
},
};

const adultMembers = ["you"];
if (isJoint) {
people.spouse = {
age: { [y]: input.spouseAge ?? 40 },
employment_income: { [y]: input.spouseEmploymentIncome ?? 0 },
};
adultMembers.push("spouse");
}

const childMembers: string[] = [];
input.childAges.forEach((age, i) => {
const id = `child_${i}`;
people[id] = { age: { [y]: age } };
childMembers.push(id);
});

const allMembers = [...adultMembers, ...childMembers];

const household: Record<string, unknown> = {
people,
families: { family: { members: allMembers } },
spm_units: { spm_unit: { members: allMembers } },
marital_units: {
adult_unit: { members: adultMembers },
...Object.fromEntries(
childMembers.map((c) => [`${c}_unit`, { members: [c] }]),
),
},
tax_units: {
tax_unit: {
members: allMembers,
filing_status: { [y]: input.filingStatus },
eitc: { [y]: null },
ctc_value: { [y]: null },
state_eitc: { [y]: null },
state_ctc: { [y]: null },
},
},
households: {
household: {
members: allMembers,
state_name: { [y]: input.state },
},
},
};

if (axes) {
household.axes = [
[
{
name: "employment_income",
period: y,
min: axes.min,
max: axes.max,
count: axes.count,
},
],
];
}

return { household };
}

function pickScalar(
result: Record<string, unknown>,
varName: string,
year: number,
): number {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const r = result as any;
const v = r?.tax_units?.tax_unit?.[varName]?.[String(year)];
return typeof v === "number" ? v : 0;
}

function pickArray(
result: Record<string, unknown>,
path: string[],
varName: string,
year: number,
): number[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cursor: any = result;
for (const seg of path) cursor = cursor?.[seg];
const v = cursor?.[varName]?.[String(year)];
return Array.isArray(v) ? v.map((x) => Number(x) || 0) : [];
}

async function callApi(body: unknown): Promise<Record<string, unknown>> {
const response = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const json = await response.json();
return json.result ?? json;
}

export async function calculateHousehold(
input: HouseholdInput,
): Promise<CreditBreakdown> {
const result = await callApi(buildHousehold(input));
return {
federalEitc: pickScalar(result, "eitc", input.year),
federalCtc: pickScalar(result, "ctc_value", input.year),
stateEitc: pickScalar(result, "state_eitc", input.year),
stateCtc: pickScalar(result, "state_ctc", input.year),
};
}

export async function sweepEarnings(
baseInput: HouseholdInput,
options: { min?: number; max?: number; count?: number } = {},
): Promise<SweepPoint[]> {
const currentEarnings = baseInput.employmentIncome;
const min = options.min ?? 0;
const max = options.max ?? Math.max(200_000, currentEarnings * 2);
const count = options.count ?? 401;

const result = await callApi(
buildHousehold(baseInput, { min, max, count }),
);
const year = baseInput.year;

const earnings = pickArray(result, ["people", "you"], "employment_income", year);
const eitc = pickArray(result, ["tax_units", "tax_unit"], "eitc", year);
const ctc = pickArray(result, ["tax_units", "tax_unit"], "ctc_value", year);
const stateEitc = pickArray(result, ["tax_units", "tax_unit"], "state_eitc", year);
const stateCtc = pickArray(result, ["tax_units", "tax_unit"], "state_ctc", year);

return earnings.map((e, i) => ({
earnings: e,
federalEitc: eitc[i] ?? 0,
federalCtc: ctc[i] ?? 0,
stateEitc: stateEitc[i] ?? 0,
stateCtc: stateCtc[i] ?? 0,
}));
}
Loading
Loading