Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/src/pages/report-output/ComparativeAnalysisPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -42,6 +43,7 @@ interface ViewComponentProps {
*/
const VIEW_MAP: Record<string, ComponentType<ViewComponentProps>> = {
'budgetary-impact-overall': BudgetaryImpactSubPage,
'budgetary-impact-by-level': BudgetaryImpactByLevelSubPage,
'budgetary-impact-by-program': BudgetaryImpactByProgramSubPage,
'distributional-impact-income-relative': DistributionalImpactIncomeRelativeSubPage,
'distributional-impact-income-average': DistributionalImpactIncomeAverageSubPage,
Expand Down
7 changes: 7 additions & 0 deletions app/src/pages/report-output/MigrationSubPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -128,6 +129,12 @@ export default function MigrationSubPage({
</CollapsibleSection>
)}

{countryId === 'us' && output.budget.federal_budgetary_impact !== undefined && (
<CollapsibleSection label="Federal vs. state budgetary impact" defaultOpen={false}>
<BudgetaryImpactByLevelSubPage output={output} />
</CollapsibleSection>
)}

{countryId === 'uk' && (
<CollapsibleSection
label="Wealth distributional analysis"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useSelector } from 'react-redux';
import type { SocietyWideReportOutput } from '@/api/societyWideCalculation';
import { ChartContainer } from '@/components/ChartContainer';
import {
computeWaterfallData,
getWaterfallDomain,
WaterfallChart,
type WaterfallDatum,
type WaterfallItem,
} from '@/components/charts';
import { Stack, Text } from '@/components/ui';
import { MOBILE_BREAKPOINT_QUERY } from '@/hooks/useChartDimensions';
import { useCurrentCountry } from '@/hooks/useCurrentCountry';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useViewportSize } from '@/hooks/useViewportSize';
import type { RootState } from '@/store';
import { absoluteChangeMessage } from '@/utils/chartMessages';
import { getClampedChartHeight, getNiceTicks } from '@/utils/chartUtils';
import { currencySymbol } from '@/utils/formatters';
import {
BudgetWaterfallTooltip,
formatBillions,
getBudgetFillColor,
makeBudgetTickFormatter,
} from './budgetChartUtils';

interface Props {
output: SocietyWideReportOutput;
chartHeight?: number;
fillHeight?: boolean;
}

export default function BudgetaryImpactByLevelSubPage({
output,
chartHeight: chartHeightProp,
fillHeight = false,
}: Props) {
const mobile = useMediaQuery(MOBILE_BREAKPOINT_QUERY);
const { height: viewportHeight } = useViewportSize();
const countryId = useCurrentCountry();
const metadata = useSelector((state: RootState) => 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 (
<Stack gap="md">
<Text size="lg" fw={500}>
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).
</Text>
</Stack>
);
}

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: <BudgetWaterfallTooltip />,
barLabelFormatter: (v: number) => formatBillions(v * 1e9, countryId),
};

if (fillHeight) {
return <WaterfallChart {...waterfallProps} fillHeight />;
}

// Suppress unused metadata warning — kept for parity with sibling sub-pages
// that consume metadata for title generation.
void metadata;

return (
<ChartContainer
title={
budgetaryImpact < 0
? `This reform costs the federal and state governments $${formatBillions(-budgetaryImpact, countryId)}`
: `This reform raises $${formatBillions(budgetaryImpact, countryId)} split between federal and state`
}
downloadFilename="budgetary-impact-by-level.svg"
>
<WaterfallChart {...waterfallProps} height={chartHeight} />
</ChartContainer>
);
}
Original file line number Diff line number Diff line change
@@ -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(<BudgetaryImpactByLevelSubPage output={makeOutput(90e9, 10e9, 100e9)} />);
expect(screen.getByText(/federal and state/i)).toBeInTheDocument();
});

test('given missing federal/state keys then shows fallback message', () => {
mockCountry = 'us';
render(<BudgetaryImpactByLevelSubPage output={makeOutput(undefined, undefined, 0)} />);
expect(
screen.getByText(/Federal vs\. state budgetary impact is available/i)
).toBeInTheDocument();
});

test('given non-US country then shows fallback message', () => {
mockCountry = 'uk';
render(<BudgetaryImpactByLevelSubPage output={makeOutput(50e9, 5e9, 55e9)} />);
expect(
screen.getByText(/Federal vs\. state budgetary impact is available/i)
).toBeInTheDocument();
});
});
5 changes: 5 additions & 0 deletions app/src/types/metadata/ReportOutputSocietyWideUS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
6 changes: 3 additions & 3 deletions changelog_entry.yaml
Original file line number Diff line number Diff line change
@@ -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.
Loading