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
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
getDetailedBudgetCsvRows,
} from '@/pages/report-output/budgetary-impact/budgetChartUtils';
import type { RootState } from '@/store';
import { absoluteChangeMessage } from '@/utils/chartMessages';
import { absoluteChangeMessage, wordWrap } from '@/utils/chartMessages';
import { getClampedChartHeight } from '@/utils/chartUtils';
import { currencySymbol, formatCurrencyAbbr } from '@/utils/formatters';

Expand All @@ -37,6 +37,8 @@ interface ProgramBudgetItem {
baseline: number;
difference: number;
reform: number;
/** Whether the program is a tax; absent from outputs built before the simulation API sent it. */
is_tax?: boolean | null;
}

/** "−£296.5m": each bar in its own unit, the minus before the currency symbol. */
Expand All @@ -46,9 +48,10 @@ function formatBarAmount(value: number, countryId: CountryId): string {
}

/**
* The benefit programmes in a detailed budget. The simulation API reports
* each programme's own total, so a benefit's change is a change in
* spending, and it costs the budget when it rises.
* The benefit programmes in a detailed budget, for outputs whose rows
* don't say whether each is a tax. The simulation API reports each
* programme's own total, so a benefit's change is a change in spending,
* and it costs the budget when it rises.
*/
const BENEFIT_PROGRAMS = new Set([
'universal_credit',
Expand All @@ -69,6 +72,15 @@ const BENEFIT_PROGRAMS = new Set([
'ctc',
]);

/**
* Whether a row is a benefit: its own `is_tax` flag when the output has
* one, which covers every programme the model reports, otherwise the
* fixed list.
*/
function isBenefit(key: string, values: ProgramBudgetItem): boolean {
return typeof values.is_tax === 'boolean' ? !values.is_tax : BENEFIT_PROGRAMS.has(key);
}

/**
* Whether the benefits' differences are spending changes, to negate into
* budget changes. The simulation API sends spending; the legacy API sent
Expand All @@ -85,7 +97,7 @@ function benefitsAreSpending(
if (typeof values?.difference !== 'number') {
continue;
}
if (BENEFIT_PROGRAMS.has(key)) {
if (isBenefit(key, values)) {
benefits += values.difference;
} else {
taxes += values.difference;
Expand All @@ -111,10 +123,33 @@ export function getChangedPrograms(
.map(([key, values]) => ({
key,
label: variableLabels[key]?.label || key,
value: negateBenefits && BENEFIT_PROGRAMS.has(key) ? -values.difference : values.difference,
value: negateBenefits && isBenefit(key, values) ? -values.difference : values.difference,
}));
}

/**
* The smallest unlisted share of the total that gets its own bar: below it
* the gap is rounding, not a program.
*/
const OTHER_PROGRAMS_MIN_SHARE = 0.001;

/**
* The part of the total budgetary impact that the listed programs don't
* add up to; 0 when they do. The total sums every tax and benefit in the
* model, but the detailed budget lists only some programs, so a change in
* any other one (such as Scottish Child Payment lost with Universal Credit)
* is in the total with no bar of its own.
*/
export function getOtherProgramsAmount(
budgetaryImpact: number,
programs: Array<{ value: number }>
): number {
const listed = programs.reduce((sum, program) => sum + program.value, 0);
const other = budgetaryImpact - listed;
const threshold = Math.max(1, OTHER_PROGRAMS_MIN_SHARE * Math.abs(budgetaryImpact));
return Number.isFinite(other) && Math.abs(other) >= threshold ? other : 0;
}

/**
* The UK report's budgetary impact by program, as v2 shows it: a
* waterfall of each program's change and the total, drawn the flagship
Expand All @@ -134,8 +169,10 @@ export default function BudgetaryImpactByProgramSubPage({
const budgetaryImpact = output.budget.budgetary_impact;
const programs = getChangedPrograms(output, metadata.variables ?? {});
const programWord = countryId === 'uk' ? 'programme' : 'program';
const otherLabel = `Other ${programWord}s`;
const other = output.detailed_budget ? getOtherProgramsAmount(budgetaryImpact, programs) : 0;

if (programs.length === 0) {
if (programs.length === 0 && other === 0) {
return (
<Stack gap="md">
<Text size="sm" c="dimmed">
Expand All @@ -149,6 +186,7 @@ export default function BudgetaryImpactByProgramSubPage({

const items: WaterfallItem[] = [
...programs.map((program) => ({ name: program.label, value: program.value })),
...(other !== 0 ? [{ name: otherLabel, value: other }] : []),
{ name: 'Total', value: budgetaryImpact, isTotal: true },
];

Expand All @@ -157,6 +195,20 @@ export default function BudgetaryImpactByProgramSubPage({
const toAmount = (valueInUnit: number) => valueInUnit * unit.divisor;

const hoverMessage = (name: string, amount: number) => {
if (name === otherLabel) {
const change = absoluteChangeMessage(
'This reform',
`the budgetary impact of ${programWord}s that this chart doesn't list separately`,
amount,
0,
(v) => formatBillions(v, countryId)
);
const why = wordWrap(
'The total includes every tax and benefit in the model, so their changes are in it without a bar of their own.',
50
);
return `${change}.\n${why}`;
}
const object =
name === 'Total' ? 'the budget deficit' : `the ${name} ${programWord}'s budgetary impact`;
return absoluteChangeMessage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,43 @@ export function withUKBreakdowns(output: SocietyWideReportOutput): SocietyWideRe
detailed_budget: UK_DETAILED_BUDGET,
} as unknown as SocietyWideReportOutput;
}

/**
* Report 4738's budget (the UK's 2027 Universal Credit freeze): the total,
* £958.5m, includes £25.0m of Scottish Child Payment that the detailed
* budget has no row for, so its rows add up to only £933.5m.
*/
export const UC_FREEZE_TOTAL = 958.5e6;
export const UC_FREEZE_DETAILED_BUDGET = {
universal_credit: { baseline: 82244.8e6, reform: 81311.3e6, difference: -933.5e6 },
child_benefit: { baseline: 14306.5e6, reform: 14306.5e6, difference: 0 },
};

/** A report's output with report 4738's UK budget and detailed budget. */
export function withUCFreezeBudget(output: SocietyWideReportOutput): SocietyWideReportOutput {
return {
...withBudget(output, { federal: 0, state: 0, benefits: -UC_FREEZE_TOTAL }),
detailed_budget: UC_FREEZE_DETAILED_BUDGET,
} as unknown as SocietyWideReportOutput;
}

/**
* Report 4738's detailed budget once the simulation API sends a row for
* every programme the reform changes, each with its `is_tax` flag:
* Scottish Child Payment, which no fixed list names, is a benefit.
*/
export const UC_FREEZE_FLAGGED_DETAILED_BUDGET = {
universal_credit: { baseline: 82244.8e6, reform: 81311.3e6, difference: -933.5e6, is_tax: false },
scottish_child_payment: { baseline: 600e6, reform: 575e6, difference: -25e6, is_tax: false },
income_tax: { baseline: 300e9, reform: 300e9, difference: 0, is_tax: true },
};

/** A report's output with report 4738's budget and its flagged detailed budget. */
export function withFlaggedUCFreezeBudget(
output: SocietyWideReportOutput
): SocietyWideReportOutput {
return {
...withBudget(output, { federal: 0, state: 0, benefits: -UC_FREEZE_TOTAL }),
detailed_budget: UC_FREEZE_FLAGGED_DETAILED_BUDGET,
} as unknown as SocietyWideReportOutput;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import { render, screen } from '@test-utils';
import { describe, expect, test, vi } from 'vitest';
import BudgetaryImpactByProgramSubPage, {
getChangedPrograms,
getOtherProgramsAmount,
} from '@/components/flagship/report/charts/BudgetaryImpactByProgramSubPage';
import DistributionalImpactIncomeAverageSubPage from '@/components/flagship/report/charts/DistributionalImpactIncomeAverageSubPage';
import WinnersLosersIncomeDecileSubPage from '@/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage';
import {
REPORT_4332_OUTPUT,
UC_FREEZE_TOTAL,
withBudget,
withFlaggedUCFreezeBudget,
withUCFreezeBudget,
withUKBreakdowns,
} from '@/tests/fixtures/components/flagship/report/charts/reportChartMocks';

Expand Down Expand Up @@ -56,7 +61,105 @@ describe('getChangedPrograms', () => {
});
});

describe('getChangedPrograms with is_tax flags', () => {
test('given flagged rows then a benefit no list names is signed as spending', () => {
// Given: report 4738 with a Scottish Child Payment row flagged as a benefit.
const output = withFlaggedUCFreezeBudget(REPORT_4332_OUTPUT);

// When
const programs = getChangedPrograms(output, {});

// Then: both benefits fell, so both save money, and they add up to the total.
expect(programs).toEqual([
{ key: 'universal_credit', label: 'universal_credit', value: 933.5e6 },
{ key: 'scottish_child_payment', label: 'scottish_child_payment', value: 25e6 },
]);
expect(getOtherProgramsAmount(UC_FREEZE_TOTAL, programs)).toBe(0);
});

test('given a row flagged as a tax then its change is signed as revenue', () => {
const output = {
...withBudget(UK_OUTPUT, { federal: 40e6, state: 0 }),
detailed_budget: {
capital_gains_tax: { baseline: 15e9, reform: 15.04e9, difference: 40e6, is_tax: true },
},
} as typeof UK_OUTPUT;

expect(getChangedPrograms(output, {})).toEqual([
{ key: 'capital_gains_tax', label: 'capital_gains_tax', value: 40e6 },
]);
});
});

describe('getOtherProgramsAmount', () => {
test('given programs that add up to the total then there is nothing else', () => {
expect(getOtherProgramsAmount(1.5e9, [{ value: 2e9 }, { value: -0.5e9 }])).toBe(0);
});

test('given a total that the programs fall short of then returns the difference', () => {
// Report 4738: £25.0m of Scottish Child Payment has no row of its own.
expect(getOtherProgramsAmount(UC_FREEZE_TOTAL, [{ value: 933.5e6 }])).toBeCloseTo(25e6);
});

test('given a difference within rounding then there is nothing else', () => {
expect(getOtherProgramsAmount(958.5e6, [{ value: 958.4e6 }])).toBe(0);
});

test('given a total that no listed program explains then returns all of it', () => {
expect(getOtherProgramsAmount(17e6, [])).toBe(17e6);
});
});

describe('BudgetaryImpactByProgramSubPage', () => {
test('given a total the programmes fall short of then an other programmes bar makes up the gap', () => {
// Given
const output = withUCFreezeBudget(REPORT_4332_OUTPUT);

// When
render(<BudgetaryImpactByProgramSubPage output={output} fillHeight />, 'uk');

// Then
expect(screen.getByText('Other programmes')).toBeInTheDocument();
expect(screen.getByText('£25.0m')).toBeInTheDocument();
expect(screen.getByText('£958.5m')).toBeInTheDocument();
});

test('given flagged rows for every programme then no other programmes bar is needed', () => {
render(
<BudgetaryImpactByProgramSubPage
output={withFlaggedUCFreezeBudget(REPORT_4332_OUTPUT)}
fillHeight
/>,
'uk'
);

expect(screen.queryByText('Other programmes')).not.toBeInTheDocument();
expect(screen.getByText('£25.0m')).toBeInTheDocument();
});

test('given programmes that add up to the total then there is no other programmes bar', () => {
render(<BudgetaryImpactByProgramSubPage output={UK_OUTPUT} fillHeight />, 'uk');

expect(screen.queryByText('Other programmes')).not.toBeInTheDocument();
});

test('given a total with no listed programme changing then the whole total is other programmes', () => {
// Given: a PIP cut, which no listed programme covers.
const output = {
...withBudget(UK_OUTPUT, { federal: 0, state: 0, benefits: -17e6 }),
detailed_budget: { universal_credit: { baseline: 1, reform: 1, difference: 0 } },
} as typeof UK_OUTPUT;

// When
render(<BudgetaryImpactByProgramSubPage output={output} fillHeight />, 'uk');

// Then
expect(screen.getByText('Other programmes')).toBeInTheDocument();
expect(
screen.queryByText('This reform has no impact on individual programmes.')
).not.toBeInTheDocument();
});

test('given no detailed budget then says it is not available', () => {
render(<BudgetaryImpactByProgramSubPage output={REPORT_4332_OUTPUT} fillHeight />, 'uk');

Expand All @@ -67,7 +170,7 @@ describe('BudgetaryImpactByProgramSubPage', () => {

test('given a budget where no program changes then says so', () => {
const output = {
...UK_OUTPUT,
...withBudget(UK_OUTPUT, { federal: 0, state: 0 }),
detailed_budget: { income_tax: { baseline: 1, reform: 1, difference: 0 } },
} as typeof UK_OUTPUT;

Expand Down
2 changes: 1 addition & 1 deletion app/src/utils/chartMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type CountryId = (typeof countryIds)[number];
* Returns newline-separated text. Consumers that render HTML (e.g. Plotly
* hover templates) should convert '\n' → '<br>' at point of use.
*/
function wordWrap(text: string, width: number = 50): string {
export function wordWrap(text: string, width: number = 50): string {
return wordwrap.wrap(text, { width });
}

Expand Down
Loading