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; }; 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.