From b832c58478cf374e57e1dd5df91a5f09040219b9 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 27 Oct 2026 15:49:06 -0400 Subject: [PATCH 1/2] Show an "Other programmes" bar when the programmes fall short of the total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UK budgetary impact by programme lists only the programmes in the detailed budget, but the total sums every tax and benefit in the model. A change in any other one (in report 4738, £25.0m of Scottish Child Payment lost with Universal Credit) was in the Total bar with no bar of its own, so the bars didn't add up and nothing said why. The gap now gets its own bar, with hover text saying what it is. A reform that changes only unlisted programmes (a PIP cut, say) now draws that bar and the total instead of saying it has no impact on individual programmes. Co-Authored-By: Claude Opus 5.5 --- .../BudgetaryImpactByProgramSubPage.tsx | 44 ++++++++++++- .../report/charts/reportChartMocks.ts | 19 ++++++ .../report/charts/UKBreakdownCharts.test.tsx | 61 ++++++++++++++++++- app/src/utils/chartMessages.ts | 2 +- 4 files changed, 122 insertions(+), 4 deletions(-) diff --git a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx index 39e6d819d..5be00da26 100644 --- a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx +++ b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx @@ -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'; @@ -115,6 +115,29 @@ export function getChangedPrograms( })); } +/** + * 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 @@ -134,8 +157,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 ( @@ -149,6 +174,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 }, ]; @@ -157,6 +183,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( diff --git a/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts b/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts index 7be03679a..4595d996f 100644 --- a/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts +++ b/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts @@ -319,3 +319,22 @@ 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; +} diff --git a/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx b/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx index 2420e9b9d..cec46a6b7 100644 --- a/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx +++ b/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx @@ -3,11 +3,15 @@ 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, + withUCFreezeBudget, withUKBreakdowns, } from '@/tests/fixtures/components/flagship/report/charts/reportChartMocks'; @@ -56,7 +60,62 @@ describe('getChangedPrograms', () => { }); }); +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(, 'uk'); + + // Then + expect(screen.getByText('Other programmes')).toBeInTheDocument(); + expect(screen.getByText('£25.0m')).toBeInTheDocument(); + expect(screen.getByText('£958.5m')).toBeInTheDocument(); + }); + + test('given programmes that add up to the total then there is no other programmes bar', () => { + render(, '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(, '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(, 'uk'); @@ -67,7 +126,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; diff --git a/app/src/utils/chartMessages.ts b/app/src/utils/chartMessages.ts index 091f91068..93ff22d24 100644 --- a/app/src/utils/chartMessages.ts +++ b/app/src/utils/chartMessages.ts @@ -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' → '
' 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 }); } From e2195deab1d0f7829f410b0fb6fc0860812bedde Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 27 Oct 2026 18:02:52 -0400 Subject: [PATCH 2/2] Sign each programme by the output's is_tax flag when it has one The chart decided which detailed-budget rows are benefits from its own BENEFIT_PROGRAMS list. Once policyengine.py adds a row for every UK budget component a reform changes, a benefit missing from that list (Scottish Child Payment, PIP) would be drawn as a tax. The simulation API now sends is_tax on each row (policyengine-sim-api#972); the chart uses it and keeps the list only for older outputs without the flag. Co-Authored-By: Claude Opus 5.5 --- .../BudgetaryImpactByProgramSubPage.tsx | 22 +++++++--- .../report/charts/reportChartMocks.ts | 21 +++++++++ .../report/charts/UKBreakdownCharts.test.tsx | 44 +++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx index 5be00da26..3f6c8909a 100644 --- a/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx +++ b/app/src/components/flagship/report/charts/BudgetaryImpactByProgramSubPage.tsx @@ -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. */ @@ -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', @@ -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 @@ -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; @@ -111,7 +123,7 @@ 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, })); } diff --git a/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts b/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts index 4595d996f..a984dba18 100644 --- a/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts +++ b/app/src/tests/fixtures/components/flagship/report/charts/reportChartMocks.ts @@ -338,3 +338,24 @@ export function withUCFreezeBudget(output: SocietyWideReportOutput): SocietyWide 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; +} diff --git a/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx b/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx index cec46a6b7..96b8da658 100644 --- a/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx +++ b/app/src/tests/unit/components/flagship/report/charts/UKBreakdownCharts.test.tsx @@ -11,6 +11,7 @@ import { REPORT_4332_OUTPUT, UC_FREEZE_TOTAL, withBudget, + withFlaggedUCFreezeBudget, withUCFreezeBudget, withUKBreakdowns, } from '@/tests/fixtures/components/flagship/report/charts/reportChartMocks'; @@ -60,6 +61,36 @@ 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); @@ -93,6 +124,19 @@ describe('BudgetaryImpactByProgramSubPage', () => { expect(screen.getByText('£958.5m')).toBeInTheDocument(); }); + test('given flagged rows for every programme then no other programmes bar is needed', () => { + render( + , + '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(, 'uk');