From 1e0bfb44d1d539e1d2e3ddbf3cd1909aa05ad391 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 19 Apr 2026 08:10:34 -0400 Subject: [PATCH 1/2] Add federal vs. state budgetary impact sub-page to US reports Adds BudgetaryImpactByLevelSubPage showing the reform's budgetary impact partitioned into federal vs. state via federal_budgetary_impact and state_budgetary_impact fields on the economy payload. Wired into ComparativeAnalysisPage at budgetary-impact-by-level, and shown as a collapsible section in the US Migration tab when the fields are present. Falls back to an explanatory message when the economy worker hasn't yet populated the new fields (older releases) or on non-US countries. Closes PolicyEngine/policyengine-app-v2#999. Depends on PolicyEngine/policyengine-api#3482 (once merged and the economy worker surfaces total_federal_benefit_cost/total_state_benefit_cost). --- .../report-output/ComparativeAnalysisPage.tsx | 2 + .../pages/report-output/MigrationSubPage.tsx | 7 + .../BudgetaryImpactByLevelSubPage.tsx | 143 ++++++++++++++++++ .../BudgetaryImpactByLevelSubPage.test.tsx | 75 +++++++++ .../metadata/ReportOutputSocietyWideUS.ts | 5 + 5 files changed, 232 insertions(+) create mode 100644 app/src/pages/report-output/budgetary-impact/BudgetaryImpactByLevelSubPage.tsx create mode 100644 app/src/tests/unit/pages/report-output/budgetary-impact/BudgetaryImpactByLevelSubPage.test.tsx diff --git a/app/src/pages/report-output/ComparativeAnalysisPage.tsx b/app/src/pages/report-output/ComparativeAnalysisPage.tsx index e74ce5ac5..e98af1698 100644 --- a/app/src/pages/report-output/ComparativeAnalysisPage.tsx +++ b/app/src/pages/report-output/ComparativeAnalysisPage.tsx @@ -1,6 +1,7 @@ import type { ComponentType } from 'react'; import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/societyWideCalculation'; import { CongressionalDistrictDataProvider } from '@/contexts/CongressionalDistrictDataContext'; +import BudgetaryImpactByLevelSubPage from './budgetary-impact/BudgetaryImpactByLevelSubPage'; import BudgetaryImpactByProgramSubPage from './budgetary-impact/BudgetaryImpactByProgramSubPage'; import BudgetaryImpactSubPage from './budgetary-impact/BudgetaryImpactSubPage'; import { AbsoluteChangeByDistrict } from './congressional-district/AbsoluteChangeByDistrict'; @@ -42,6 +43,7 @@ interface ViewComponentProps { */ const VIEW_MAP: Record> = { 'budgetary-impact-overall': BudgetaryImpactSubPage, + 'budgetary-impact-by-level': BudgetaryImpactByLevelSubPage, 'budgetary-impact-by-program': BudgetaryImpactByProgramSubPage, 'distributional-impact-income-relative': DistributionalImpactIncomeRelativeSubPage, 'distributional-impact-income-average': DistributionalImpactIncomeAverageSubPage, diff --git a/app/src/pages/report-output/MigrationSubPage.tsx b/app/src/pages/report-output/MigrationSubPage.tsx index 2591acd03..3b69c6b81 100644 --- a/app/src/pages/report-output/MigrationSubPage.tsx +++ b/app/src/pages/report-output/MigrationSubPage.tsx @@ -16,6 +16,7 @@ import type { Geography } from '@/types/ingredients/Geography'; import type { Report } from '@/types/ingredients/Report'; import type { Simulation } from '@/types/ingredients/Simulation'; import { isUKLocalLevelGeography } from '@/utils/geographyUtils'; +import BudgetaryImpactByLevelSubPage from './budgetary-impact/BudgetaryImpactByLevelSubPage'; import BudgetaryImpactByProgramSubPage from './budgetary-impact/BudgetaryImpactByProgramSubPage'; import { ConstituencySubPage } from './ConstituencySubPage'; import DistributionalImpactWealthAverageSubPage from './distributional-impact/DistributionalImpactWealthAverageSubPage'; @@ -128,6 +129,12 @@ export default function MigrationSubPage({ )} + {countryId === 'us' && output.budget.federal_budgetary_impact !== undefined && ( + + + + )} + {countryId === 'uk' && ( state.metadata); + const chartHeight = chartHeightProp ?? getClampedChartHeight(viewportHeight, mobile); + + const budget = output.budget; + const federalBudgetaryImpact = budget.federal_budgetary_impact; + const stateBudgetaryImpact = budget.state_budgetary_impact; + + // If the economy worker didn't populate the new keys (older releases, or + // non-US countries), fall back to a message pointing users at the total + // chart. Breaking the chart silently is worse than an explicit note. + if ( + federalBudgetaryImpact === undefined || + stateBudgetaryImpact === undefined || + countryId !== 'us' + ) { + return ( + + + Federal vs. state budgetary impact is available only for US reforms, and requires the + simulation to include FMAP-based Medicaid/CHIP cost attribution (shipping in the next + policyengine-us release). + + + ); + } + + const budgetaryImpact = budget.budgetary_impact; + + // Values in billions + const valuesBeforeFilter = [ + federalBudgetaryImpact / 1e9, + stateBudgetaryImpact / 1e9, + budgetaryImpact / 1e9, + ]; + const labelsBeforeFilter = mobile + ? ['Federal', 'State', 'Net'] + : ['Federal budgetary impact', 'State budgetary impact', 'Net impact']; + + const values = valuesBeforeFilter.filter((v) => v !== 0); + const labels = labelsBeforeFilter.filter((_l, i) => valuesBeforeFilter[i] !== 0); + + const hoverMessage = (name: string, valueBn: number) => { + const nameLower = name.toLowerCase(); + const yValue = valueBn * 1e9; + const obj = nameLower.includes('net') + ? 'the combined budget deficit' + : nameLower.includes('federal') + ? 'the federal budget deficit' + : 'state budget deficits'; + return absoluteChangeMessage('This reform', obj, -yValue, 0, (v) => + formatBillions(v, countryId) + ); + }; + + const items: WaterfallItem[] = values.map((value, i) => ({ + name: labels[i], + value, + isTotal: i === values.length - 1 && values.length > 1, + })); + + const data = computeWaterfallData(items, (v) => formatBillions(v * 1e9, countryId)); + const dataWithHover = data.map((d) => ({ + ...d, + hoverText: hoverMessage(d.name, d.value), + })); + + const yDomain = getWaterfallDomain(data); + const yTicks = getNiceTicks(yDomain); + const symbol = currencySymbol(countryId); + const tickFormatter = makeBudgetTickFormatter(symbol, yDomain); + + const waterfallProps = { + data: dataWithHover, + yDomain, + yTicks, + yAxisLabel: 'Budgetary impact (bn)' as const, + yTickFormatter: tickFormatter, + fillColor: (d: WaterfallDatum) => getBudgetFillColor(d, budgetaryImpact), + tooltipContent: , + barLabelFormatter: (v: number) => formatBillions(v * 1e9, countryId), + }; + + if (fillHeight) { + return ; + } + + // Suppress unused metadata warning — kept for parity with sibling sub-pages + // that consume metadata for title generation. + void metadata; + + return ( + + + + ); +} diff --git a/app/src/tests/unit/pages/report-output/budgetary-impact/BudgetaryImpactByLevelSubPage.test.tsx b/app/src/tests/unit/pages/report-output/budgetary-impact/BudgetaryImpactByLevelSubPage.test.tsx new file mode 100644 index 000000000..2a51b5b80 --- /dev/null +++ b/app/src/tests/unit/pages/report-output/budgetary-impact/BudgetaryImpactByLevelSubPage.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; +import BudgetaryImpactByLevelSubPage from '@/pages/report-output/budgetary-impact/BudgetaryImpactByLevelSubPage'; + +// Mock Recharts to avoid rendering SVG in tests +vi.mock('recharts', () => ({ + Bar: vi.fn(() => null), + BarChart: vi.fn(({ children }) => children), + CartesianGrid: vi.fn(() => null), + Cell: vi.fn(() => null), + Label: vi.fn(() => null), + ReferenceLine: vi.fn(() => null), + ResponsiveContainer: vi.fn(({ children }) => children), + Tooltip: vi.fn(() => null), + XAxis: vi.fn(() => null), + YAxis: vi.fn(() => null), +})); + +let mockCountry = 'us'; +vi.mock('@/hooks/useCurrentCountry', () => ({ + useCurrentCountry: () => mockCountry, +})); + +vi.mock('@/utils/chartUtils', () => ({ + DEFAULT_CHART_CONFIG: { displayModeBar: false }, + downloadChartAsSvg: vi.fn(), + downloadCsv: vi.fn(), + getChartLogoImage: vi.fn(() => ({})), + getClampedChartHeight: vi.fn(() => 500), + getNiceTicks: vi.fn(() => [0, 5, 10]), + getYAxisLayout: vi.fn(() => ({ yAxisWidth: 60, marginLeft: 10, labelDx: -20 })), + RECHARTS_FONT_STYLE: { fontFamily: 'Inter', fontSize: 12 }, + RECHARTS_WATERMARK: { src: '/test.png', width: 80, opacity: 0.8 }, +})); + +const makeOutput = ( + federal: number | undefined, + state: number | undefined, + total: number +): SocietyWideReportOutput => + ({ + budget: { + budgetary_impact: total, + federal_budgetary_impact: federal, + state_budgetary_impact: state, + tax_revenue_impact: 0, + state_tax_revenue_impact: 0, + benefit_spending_impact: 0, + }, + }) as SocietyWideReportOutput; + +describe('BudgetaryImpactByLevelSubPage', () => { + test('given US output with federal and state impact then renders chart title with split', () => { + mockCountry = 'us'; + render(); + expect(screen.getByText(/federal and state/i)).toBeInTheDocument(); + }); + + test('given missing federal/state keys then shows fallback message', () => { + mockCountry = 'us'; + render(); + expect( + screen.getByText(/Federal vs\. state budgetary impact is available/i) + ).toBeInTheDocument(); + }); + + test('given non-US country then shows fallback message', () => { + mockCountry = 'uk'; + render(); + expect( + screen.getByText(/Federal vs\. state budgetary impact is available/i) + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/types/metadata/ReportOutputSocietyWideUS.ts b/app/src/types/metadata/ReportOutputSocietyWideUS.ts index 35f65afed..8a1831589 100644 --- a/app/src/types/metadata/ReportOutputSocietyWideUS.ts +++ b/app/src/types/metadata/ReportOutputSocietyWideUS.ts @@ -5,7 +5,12 @@ export interface ReportOutputSocietyWideUS { baseline_net_income: number; benefit_spending_impact: number; budgetary_impact: number; + federal_budgetary_impact?: number; + federal_tax_revenue_impact?: number; + federal_benefit_spending_impact?: number; households: number; + state_budgetary_impact?: number; + state_benefit_spending_impact?: number; state_tax_revenue_impact: number; tax_revenue_impact: number; }; From 1b8c9076429e98abfce6d7751690abe2f4629e55 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 19 Apr 2026 08:13:18 -0400 Subject: [PATCH 2/2] Add changelog entry for federal/state budget view --- changelog_entry.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index a8ff91862..aab4f4048 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,4 +1,4 @@ -- bump: patch +- bump: minor changes: - removed: - - Temporarily remove energy price shock UK interactive from apps listing and sitemap + added: + - Federal vs. state budgetary impact sub-page on US reform reports, driven by the new federal_budgetary_impact and state_budgetary_impact fields from policyengine-api. Shows a waterfall with federal share, state share, and total, with a fallback message when the economy worker has not yet populated the new fields.