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
22 changes: 20 additions & 2 deletions app/src/components/charts/waterfallUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface WaterfallItem {
value: number;
/** Whether this item is the total bar (anchored to 0) */
isTotal?: boolean;
/** Whether this item stands apart: anchored to 0, and left out of the running total */
isStandalone?: boolean;
}

export interface WaterfallDatum {
Expand All @@ -36,6 +38,8 @@ export interface WaterfallDatum {
label: string;
/** Whether this is the total bar */
isTotal: boolean;
/** Whether this bar stands apart from the running total */
isStandalone?: boolean;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -92,9 +96,19 @@ export function computeWaterfallData(
let runningTotal = 0;

for (const item of items) {
const { name, value, isTotal = false } = item;
const { name, value, isTotal = false, isStandalone = false } = item;

if (isTotal) {
if (isStandalone) {
const metrics = computeBarMetrics(0, value, value);
result.push({
name,
value,
...metrics,
label: formatValue(value),
isTotal: false,
isStandalone: true,
});
} else if (isTotal) {
const metrics = computeBarMetrics(0, runningTotal, runningTotal);
result.push({
name,
Expand Down Expand Up @@ -158,6 +172,10 @@ export function computeWaterfallConnectors(data: WaterfallDatum[]): WaterfallCon
const connectors: WaterfallConnector[] = [];
for (let i = 0; i < data.length - 1; i++) {
const d = data[i];
// A standalone bar isn't part of the running total, so no line joins it.
if (d.isStandalone || data[i + 1].isStandalone) {
continue;
}
const y = d.runningTotalEdge === 'top' ? d.base + d.barHeight : d.base;
connectors.push({ y, fromIndex: i, toIndex: i + 1 });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,40 @@ const BENEFIT_PROGRAMS = new Set([
'ctc',
]);

/**
* Rows the total budgetary impact doesn't include, with why, to follow
* "Not in the total:" in a bar's hover text. The total is
* the change in the taxes households pay less the change in the benefits
* they get. Their changes are shown but not added up against the total.
*/
const OUTSIDE_TOTAL_REASONS: Record<string, string> = {
ni_employer: 'employers pay it, so the total of household taxes and benefits leaves it out.',
tax_credits:
'it is what families are entitled to; the total counts the Working Tax Credit and Child Tax Credit they claim.',
};

/** Short names for the rows outside the total, for a narrow axis. */
const OUTSIDE_TOTAL_SHORT_NAMES: Record<string, string> = {
ni_employer: 'Employer NI',
tax_credits: 'Tax credits',
};

/**
* Rows dropped where rows that cover the same money are listed:
* `tax_credits` is the entitlement behind Working Tax Credit and Child Tax
* Credit, and `vat` changes by as much as `vat_change`, the VAT above
* current law's that the total counts.
*/
const REPEATED_ROWS: Record<string, string[]> = {
tax_credits: ['working_tax_credit', 'child_tax_credit'],
vat: ['vat_change'],
};

/** Whether a row repeats others the detailed budget lists. */
function isRepeatedRow(key: string, detailedBudget: Record<string, unknown>): boolean {
return (REPEATED_ROWS[key] ?? []).some((repeated) => repeated in detailedBudget);
}

/**
* 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
Expand All @@ -94,7 +128,11 @@ function benefitsAreSpending(
let taxes = 0;
let benefits = 0;
for (const [key, values] of Object.entries(detailedBudget)) {
if (typeof values?.difference !== 'number') {
if (
typeof values?.difference !== 'number' ||
key in OUTSIDE_TOTAL_REASONS ||
isRepeatedRow(key, detailedBudget)
) {
continue;
}
if (isBenefit(key, values)) {
Expand All @@ -119,14 +157,24 @@ export function getChangedPrograms(
const detailedBudget = (output.detailed_budget ?? {}) as Record<string, ProgramBudgetItem>;
const negateBenefits = benefitsAreSpending(detailedBudget, output.budget?.budgetary_impact);
return Object.entries(detailedBudget)
.filter(([, values]) => typeof values?.difference === 'number' && values.difference !== 0)
.filter(
([key, values]) =>
typeof values?.difference === 'number' &&
values.difference !== 0 &&
!isRepeatedRow(key, detailedBudget)
)
.map(([key, values]) => ({
key,
label: variableLabels[key]?.label || key,
value: negateBenefits && isBenefit(key, values) ? -values.difference : values.difference,
}));
}

/** Whether a row's change is left out of the total budgetary impact. */
export function isOutsideTotal(key: string | undefined): boolean {
return key !== undefined && key in OUTSIDE_TOTAL_REASONS;
}

/**
* The smallest unlisted share of the total that gets its own bar: below it
* the gap is rounding, not a program.
Expand All @@ -138,13 +186,16 @@ const OTHER_PROGRAMS_MIN_SHARE = 0.001;
* 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.
* is in the total with no bar of its own. Rows outside the total don't
* count towards it.
*/
export function getOtherProgramsAmount(
budgetaryImpact: number,
programs: Array<{ value: number }>
programs: Array<{ key?: string; value: number }>
): number {
const listed = programs.reduce((sum, program) => sum + program.value, 0);
const listed = programs
.filter((program) => !isOutsideTotal(program.key))
.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;
Expand All @@ -171,6 +222,12 @@ export default function BudgetaryImpactByProgramSubPage({
const programWord = countryId === 'uk' ? 'programme' : 'program';
const otherLabel = `Other ${programWord}s`;
const other = output.detailed_budget ? getOtherProgramsAmount(budgetaryImpact, programs) : 0;
// Rows the total leaves out, by bar name: they stand after the total.
const outsideTotal = new Map(
programs
.filter((program) => isOutsideTotal(program.key))
.map((program) => [`${program.label} (not in total)`, program])
);

if (programs.length === 0 && other === 0) {
return (
Expand All @@ -184,10 +241,19 @@ export default function BudgetaryImpactByProgramSubPage({
);
}

// Rows the total leaves out stand after it, each from zero, so the bars
// before it still add up to the total.
const items: WaterfallItem[] = [
...programs.map((program) => ({ name: program.label, value: program.value })),
...programs
.filter((program) => !isOutsideTotal(program.key))
.map((program) => ({ name: program.label, value: program.value })),
...(other !== 0 ? [{ name: otherLabel, value: other }] : []),
{ name: 'Total', value: budgetaryImpact, isTotal: true },
...[...outsideTotal].map(([name, program]) => ({
name,
value: program.value,
isStandalone: true,
})),
];

// The unit comes from the bars' full extents, running totals included.
Expand All @@ -209,15 +275,22 @@ export default function BudgetaryImpactByProgramSubPage({
);
return `${change}.\n${why}`;
}
const label = outsideTotal.get(name)?.label ?? name;
const object =
name === 'Total' ? 'the budget deficit' : `the ${name} ${programWord}'s budgetary impact`;
return absoluteChangeMessage(
name === 'Total' ? 'the budget deficit' : `the ${label} ${programWord}'s budgetary impact`;
const change = absoluteChangeMessage(
'This reform',
object,
name === 'Total' ? -amount : amount,
0,
(v) => formatBillions(v, countryId)
);
const outside = outsideTotal.get(name);
if (!outside) {
return change;
}
const why = wordWrap(`Not in the total: ${OUTSIDE_TOTAL_REASONS[outside.key]}`, 50);
return `${change}.\n${why}`;
};

const data = computeWaterfallData(
Expand All @@ -232,6 +305,13 @@ export default function BudgetaryImpactByProgramSubPage({
const unitName = `${symbol}${unit.suffix}`;
const chartProps = {
data,
// A narrow axis shows a bar outside the total by its short name.
shortNames: Object.fromEntries(
[...outsideTotal].map(([name, program]) => [
name,
`${OUTSIDE_TOTAL_SHORT_NAMES[program.key] ?? program.label} (not in total)`,
])
),
yAxisTitles: [`Budgetary impact (${unitName})`, `Impact (${unitName})`],
formatTick: (value: number, decimals: number) =>
formatUnitAmount(value, unit, symbol, decimals),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,24 @@ export function withFlaggedUCFreezeBudget(
detailed_budget: UC_FREEZE_FLAGGED_DETAILED_BUDGET,
} as unknown as SocietyWideReportOutput;
}

/**
* An employer NI threshold rise from £5,000 to £9,100 (2027-28): employers
* pay £15.5bn less employer NI, which the total leaves out, and pass it on
* as pay, so income tax, NI and Universal Credit add up to the £5.3bn total.
*/
export const EMPLOYER_NI_TOTAL = 5.3e9;
export const EMPLOYER_NI_DETAILED_BUDGET = {
income_tax: { baseline: 300e9, reform: 304e9, difference: 4e9 },
national_insurance: { baseline: 150e9, reform: 150.9e9, difference: 0.9e9 },
universal_credit: { baseline: 82e9, reform: 81.6e9, difference: -0.4e9 },
ni_employer: { baseline: 120e9, reform: 104.5e9, difference: -15.5e9 },
};

/** A report's output with the employer NI threshold rise's budget. */
export function withEmployerNIBudget(output: SocietyWideReportOutput): SocietyWideReportOutput {
return {
...withBudget(output, { federal: 4.9e9, state: 0, benefits: -0.4e9 }),
detailed_budget: EMPLOYER_NI_DETAILED_BUDGET,
} as unknown as SocietyWideReportOutput;
}
32 changes: 32 additions & 0 deletions app/src/tests/unit/components/charts/waterfallUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ import {
const fmt = (v: number) => `$${v}`;

describe('computeWaterfallData', () => {
it('given a standalone item then anchors it to 0 and leaves the running total alone', () => {
const items: WaterfallItem[] = [
{ name: 'A', value: 10 },
{ name: 'Total', value: 0, isTotal: true },
{ name: 'Apart', value: -4, isStandalone: true },
];

const result = computeWaterfallData(items, fmt);

expect(result[1]).toMatchObject({ value: 10, isTotal: true });
expect(result[2]).toMatchObject({
base: -4,
value: -4,
range: [-4, 0],
isTotal: false,
isStandalone: true,
});
});

it('stacks all-positive values upward from 0', () => {
const items: WaterfallItem[] = [
{ name: 'A', value: 10 },
Expand Down Expand Up @@ -302,6 +321,19 @@ describe('getWaterfallDomain', () => {
});

describe('computeWaterfallConnectors', () => {
it('given a standalone bar then no connector joins it', () => {
const data = computeWaterfallData(
[
{ name: 'A', value: 10 },
{ name: 'Total', value: 0, isTotal: true },
{ name: 'Apart', value: -4, isStandalone: true },
],
fmt
);

expect(computeWaterfallConnectors(data)).toEqual([{ y: 10, fromIndex: 0, toIndex: 1 }]);
});

it('connects all-positive bars at running totals', () => {
const data = computeWaterfallData(
[
Expand Down
Loading
Loading