diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx
index bf7e8b0e9..ebbb5bf10 100644
--- a/app/src/components/flagship/CalibrationMatches.tsx
+++ b/app/src/components/flagship/CalibrationMatches.tsx
@@ -9,6 +9,7 @@ import {
calibrationFitForGeography,
CalibrationMatches,
calibrationMatchesForPaths,
+ calibrationPreviewTargets,
CalibrationRing,
calibrationTargetLabel,
CalibrationTargetRow,
@@ -395,6 +396,52 @@ export function GeographyFitSection({
);
}
+/**
+ * One calibration group's target rows: its headline totals and largest
+ * gaps first (calibrationPreviewTargets), the rest after "Show all".
+ */
+function CalibrationGroupTargets({
+ targets,
+ group,
+ countryId,
+}: {
+ targets: CalibrationTargetRow[];
+ group: string;
+ countryId: CalibrationCountry;
+}) {
+ const [showAll, setShowAll] = useState(false);
+ const { ordered, previewCount } = calibrationPreviewTargets(targets);
+ const visible = showAll ? ordered : ordered.slice(0, previewCount);
+ return (
+ <>
+ {visible.map((target) => (
+
+ |
+
+ |
+
+
+ ))}
+ {ordered.length > previewCount && (
+
+ |
+
+ |
+
+ )}
+ >
+ );
+}
+
export function CalibrationMatchSection({
matches,
pin,
@@ -502,14 +549,7 @@ export function CalibrationMatchSection({
- {match.targets.map((target) => (
-
- |
-
- |
-
-
- ))}
+
);
})}
diff --git a/app/src/components/flagship/ScorecardComparisons.tsx b/app/src/components/flagship/ScorecardComparisons.tsx
index ef2ce855d..8d805388f 100644
--- a/app/src/components/flagship/ScorecardComparisons.tsx
+++ b/app/src/components/flagship/ScorecardComparisons.tsx
@@ -11,6 +11,7 @@ import {
import {
claimPeriod,
claimValue,
+ focusRelatedReformClaims,
type RelatedScorecardClaim,
type ScorecardComparisonsResult,
} from '@/libs/flagship/scorecardComparisons';
@@ -399,6 +400,11 @@ export interface ClaimPresentation {
export interface ScorecardClaimEntry {
row: RelatedScorecardClaim;
presentation?: ClaimPresentation;
+ /**
+ * A related reform's score: a different policy, so its own baseline says
+ * nothing about this report, and no baseline note runs under it.
+ */
+ related?: boolean;
}
function claimTitle(row: RelatedScorecardClaim, presentation?: ClaimPresentation): string {
@@ -558,13 +564,17 @@ export function claimGap(row: RelatedScorecardClaim): string {
}
/** One claim as a table row: what is compared, both values, and the gap. */
-function ScorecardClaimRow({ row, presentation }: ScorecardClaimEntry) {
+function ScorecardClaimRow({ row, presentation, related = false }: ScorecardClaimEntry) {
const sourceUrl = safeUrl(row.url);
const smallLink = { ...linkStyle, fontSize: typography.fontSize.xs };
// Only a period that is recorded; "not recorded" under every value says nothing.
const publishedNote = presentation?.externalPeriod ?? claimPeriod(row);
const peNote = presentation?.modelPeriod ?? modelPeriod(row);
- const notes = differenceNotes(row, presentation?.externalPeriod, presentation?.modelPeriod);
+ const notes = differenceNotes(
+ row,
+ presentation?.externalPeriod,
+ presentation?.modelPeriod
+ ).filter((note) => !(related && note.startsWith('Different baselines')));
return (
<>
@@ -644,6 +654,7 @@ export function ScorecardClaimTable({
key={entry.row.claim_id}
row={entry.row}
presentation={entry.presentation}
+ related={entry.related}
/>
))}
@@ -653,16 +664,36 @@ export function ScorecardClaimTable({
/** Related reforms shown before the reader asks for all of them. */
export const RELATED_REFORM_PREVIEW = 10;
-/** Published scorecard estimates of other policies that change the same rules. */
+/**
+ * A related reform's title: the measure as the source describes it, up to
+ * its first comma ("Universal Credit Child Element: Remove the two child
+ * limit from April 2026"), when the claim records one; else the claim's.
+ */
+function relatedReformPresentation(row: RelatedScorecardClaim): ClaimPresentation | undefined {
+ const hint = row.conditions?.reform_hint?.split(',')[0]?.trim();
+ return hint ? { title: hint } : undefined;
+}
+
+/**
+ * Published scorecard estimates of other policies that change the same
+ * rules: closed until the reader opens them, since they do not score this
+ * reform. One score per measure and year (focusRelatedReformClaims).
+ */
export function RelatedReformEstimates({ rows }: { rows: RelatedScorecardClaim[] }) {
+ const [open, setOpen] = useState(false);
const [showAll, setShowAll] = useState(false);
+ const focused = focusRelatedReformClaims(rows);
// A broad measure, such as a threshold freeze, has hundreds of published
// scores. Show the ones with a PolicyEngine value first.
const ordered = [
- ...rows.filter((row) => typeof row.latest?.value === 'number'),
- ...rows.filter((row) => typeof row.latest?.value !== 'number'),
+ ...focused.filter((row) => typeof row.latest?.value === 'number'),
+ ...focused.filter((row) => typeof row.latest?.value !== 'number'),
];
+ if (ordered.length === 0) {
+ return null;
+ }
const visible = showAll ? ordered : ordered.slice(0, RELATED_REFORM_PREVIEW);
+ const toggleStyle = { ...rowButtonStyle, alignSelf: 'flex-start' };
return (
Related reforms
@@ -670,18 +701,39 @@ export function RelatedReformEstimates({ rows }: { rows: RelatedScorecardClaim[]
Published scores of policies that change the same rules. They do not score this exact
reform.
- ({ row }))} />
- {ordered.length > RELATED_REFORM_PREVIEW && (
-
+
+ {open && (
+ <>
+ ({
+ row,
+ presentation: relatedReformPresentation(row),
+ related: true,
+ }))}
+ />
+ {ordered.length > RELATED_REFORM_PREVIEW && (
+
+ )}
+ >
)}
);
diff --git a/app/src/libs/flagship/calibrationMatching.ts b/app/src/libs/flagship/calibrationMatching.ts
index aec41ced6..618945e58 100644
--- a/app/src/libs/flagship/calibrationMatching.ts
+++ b/app/src/libs/flagship/calibrationMatching.ts
@@ -124,6 +124,33 @@ export function calibrationValueKind(
return target.measure === 'count' ? 'count' : 'number';
}
+/** Targets a calibration group shows before the reader asks for all of them. */
+export const CALIBRATION_PREVIEW = 6;
+
+/**
+ * A group's targets in the order the table shows them: its headline totals
+ * first, the largest money target and the largest count target (a
+ * programme's spending and its caseload), then the rest in the order given
+ * (largest gap first). The first `limit` show at once; the rest wait for
+ * "Show all".
+ */
+export function calibrationPreviewTargets(
+ targets: CalibrationTargetRow[],
+ limit: number = CALIBRATION_PREVIEW
+): { ordered: CalibrationTargetRow[]; previewCount: number } {
+ const largest = (kind: CalibrationValueKind) =>
+ targets
+ .filter((target) => calibrationValueKind(target) === kind && target.target !== null)
+ .reduce<
+ CalibrationTargetRow | undefined
+ >((best, target) => (!best || Math.abs(target.target!) > Math.abs(best.target!) ? target : best), undefined);
+ const headline = [largest('money'), largest('count')].filter(
+ (target): target is CalibrationTargetRow => target !== undefined
+ );
+ const ordered = [...headline, ...targets.filter((target) => !headline.includes(target))];
+ return { ordered, previewCount: Math.min(limit, ordered.length) };
+}
+
/**
* A target's official or modelled value: compact for the table ("$3.2B",
* "20.6K"), exact for its details ("$3,212,345.68", "20,601").
diff --git a/app/src/libs/flagship/scorecardComparisons.ts b/app/src/libs/flagship/scorecardComparisons.ts
index a1684b6f1..a006c8c64 100644
--- a/app/src/libs/flagship/scorecardComparisons.ts
+++ b/app/src/libs/flagship/scorecardComparisons.ts
@@ -17,6 +17,10 @@ export interface ScorecardClaim {
sign_convention?: string | null;
/** "unsigned" when the source prints a size and names the direction in words. */
change_direction?: string | null;
+ /** UK costings: "static", or "post_behavioural" for the leg after behaviour changes. */
+ scoring_method?: string | null;
+ /** UK costings: the measure as the source's table describes it. */
+ reform_hint?: string | null;
};
program?: string | null;
geography?: string;
@@ -58,6 +62,34 @@ export interface ScorecardClaim {
export interface RelatedScorecardClaim extends ScorecardClaim {
matchBasis: 'variable' | 'parameter' | 'program' | 'related-policy';
}
+/**
+ * Related reforms as a reader can take them in: one score per published
+ * measure, period and metric, and none that says nothing. A costing that
+ * prints a static leg and a post-behavioural leg for the same year keeps
+ * the static one, which PolicyEngine's static runs compare with (else the
+ * one PolicyEngine marks comparable). A year in which both the source and
+ * PolicyEngine score exactly zero, such as a year before the measure
+ * starts, is left out.
+ */
+export function focusRelatedReformClaims(rows: RelatedScorecardClaim[]): RelatedScorecardClaim[] {
+ const rank = (row: RelatedScorecardClaim) =>
+ (row.conditions?.scoring_method === 'static' ? 0 : 2) +
+ ((row.latest?.status_effective ?? row.latest?.status) === 'comparable' ? 0 : 1);
+ const keyOf = (row: RelatedScorecardClaim) =>
+ [row.measure?.key ?? row.name, row.period, row.metric].join('|');
+ const kept = new Map();
+ for (const row of rows) {
+ if (row.external_value === 0 && row.latest?.value === 0) {
+ continue;
+ }
+ const current = kept.get(keyOf(row));
+ if (!current || rank(row) < rank(current)) {
+ kept.set(keyOf(row), row);
+ }
+ }
+ return rows.filter((row) => kept.get(keyOf(row)) === row);
+}
+
export interface ScorecardComparisonsResult {
built: string | null;
rows: RelatedScorecardClaim[];
diff --git a/app/src/tests/fixtures/libs/flagship/calibrationMatchingMocks.ts b/app/src/tests/fixtures/libs/flagship/calibrationMatchingMocks.ts
index 359076820..f756078da 100644
--- a/app/src/tests/fixtures/libs/flagship/calibrationMatchingMocks.ts
+++ b/app/src/tests/fixtures/libs/flagship/calibrationMatchingMocks.ts
@@ -247,3 +247,38 @@ export const mockDcFitRows: CalibrationTargetRow[] = [
}
),
];
+
+/** A UK programme's targets as the dashboard sorts them, largest gap first. */
+const ucRow = (
+ name: string,
+ measure: 'total' | 'count',
+ target: number,
+ relativeError: number
+): CalibrationTargetRow => ({
+ name,
+ source: 'dwp',
+ sourceLabel: 'Department for Work and Pensions',
+ variable: 'dwp.universal_credit',
+ policyengineVariables: ['universal_credit'],
+ measure,
+ breakdown: name,
+ level: 'country',
+ geography: 'Great Britain',
+ period: 2025,
+ target,
+ estimate: target * (1 + relativeError),
+ relativeError,
+ targetRole: null,
+ sourceUrl: null,
+ targetLabel: name,
+ variableLabel: 'Universal Credit',
+ currency: measure === 'total' ? 'GBP' : undefined,
+});
+export const UC_SPENDING_ROW = ucRow('Universal Credit expenditure', 'total', 76.1e9, -0.0004);
+export const UC_HOUSEHOLDS_ROW = ucRow('Universal Credit households', 'count', 6.2e6, 0.003);
+/** Eight band targets, each with a larger gap than the two totals. */
+export const UC_BAND_ROWS = Array.from({ length: 8 }, (_, index) =>
+ ucRow(`Payment band ${index + 1}`, 'count', 40_000, 0.1 - index * 0.01)
+);
+/** The dashboard's order: the bands' larger gaps first, the totals last. */
+export const UC_TARGETS_BY_GAP = [...UC_BAND_ROWS, UC_HOUSEHOLDS_ROW, UC_SPENDING_ROW];
diff --git a/app/src/tests/fixtures/libs/flagship/scorecardComparisonMocks.ts b/app/src/tests/fixtures/libs/flagship/scorecardComparisonMocks.ts
index 51083c6eb..ebf14a860 100644
--- a/app/src/tests/fixtures/libs/flagship/scorecardComparisonMocks.ts
+++ b/app/src/tests/fixtures/libs/flagship/scorecardComparisonMocks.ts
@@ -76,3 +76,31 @@ export const ukMeasureClaim = (overrides: Partial = {}): Scoreca
},
...overrides,
});
+
+/** HM Treasury's two-child-limit costing: a static and a post-behavioural leg for each year. */
+export const TWO_CHILD_LIMIT_HINT =
+ 'Universal Credit Child Element: Remove the two child limit from April 2026, taking 450,000 children out of poverty';
+export const twoChildLimitClaim = (
+ period: number,
+ scoringMethod: 'static' | 'post_behavioural',
+ externalValue: number,
+ peValue: number
+): ScorecardClaim => ({
+ ...scorecardClaim({
+ claim_id: `two-child-${period}-${scoringMethod}`,
+ country: 'UK',
+ source: 'hm_treasury',
+ unit_concept: 'gbp',
+ name: 'Budget 2025: Policy costings (PU 3595)',
+ period,
+ external_value: externalValue,
+ claim_baseline: 'current_law',
+ conditions: { scoring_method: scoringMethod, reform_hint: TWO_CHILD_LIMIT_HINT },
+ measure: { key: 'ab2025__uc_child_element_remove_two_child_limit' },
+ latest: {
+ value: peValue,
+ status: scoringMethod === 'static' ? 'comparable' : 'concept_mismatch',
+ baseline: 'pre_ab2025',
+ },
+ }),
+});
diff --git a/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx b/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx
index 2d9b22eba..ccf4fdf85 100644
--- a/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx
+++ b/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx
@@ -18,6 +18,7 @@ import {
jctCtcClaim,
scorecardClaim,
stackedJctCtcClaim,
+ twoChildLimitClaim,
utahBaselineClaim,
} from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks';
import {
@@ -29,9 +30,10 @@ describe('scorecard evidence presentation', () => {
test('given a related reform then shows source-policy counterparts, periods and baseline caveats', () => {
render();
expect(screen.getByText(/do not score this exact reform/)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Show 1 related reform score' }));
expect(screen.getByRole('columnheader', { name: 'PolicyEngine' })).toBeInTheDocument();
expect(screen.getByText(/2026 · fiscal year/)).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button'));
+ fireEvent.click(screen.getByRole('button', { name: 'CTC extension' }));
expect(screen.getByText('Different stacking baseline')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /Find external estimates/ })
@@ -49,6 +51,11 @@ describe('scorecard evidence presentation', () => {
matchBasis: 'parameter' as const,
}));
render();
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: `Show ${RELATED_REFORM_PREVIEW + 2} related reform scores`,
+ })
+ );
const table = screen.getByRole('table', { name: 'Related reforms' });
const titles = () =>
within(table)
@@ -62,6 +69,49 @@ describe('scorecard evidence presentation', () => {
expect(titles()).toHaveLength(RELATED_REFORM_PREVIEW + 2);
});
+ test('given related reforms then they stay closed until the reader opens them', () => {
+ render();
+
+ expect(screen.queryByRole('table', { name: 'Related reforms' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Show 1 related reform score' })).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+ });
+
+ test('given a costing with static and post-behavioural legs then one row per year shows, the static one, under the measure name', async () => {
+ const rows = [
+ twoChildLimitClaim(2026, 'static', 0, 0),
+ twoChildLimitClaim(2026, 'post_behavioural', 0, 0),
+ twoChildLimitClaim(2027, 'static', -2_055_000_000, -2_308_547_892),
+ twoChildLimitClaim(2027, 'post_behavioural', -2_400_000_000, -2_308_547_892),
+ ].map((row) => ({ ...row, matchBasis: 'variable' as const }));
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Show 1 related reform score' }));
+
+ const table = screen.getByRole('table', { name: 'Related reforms' });
+ const titles = within(table)
+ .getAllByRole('rowheader')
+ .map((cell) => cell.textContent);
+ expect(titles).toHaveLength(1);
+ expect(titles[0]).toContain(
+ 'Universal Credit Child Element: Remove the two child limit from April 2026'
+ );
+ expect(within(table).getByText('-£2.1B')).toBeInTheDocument();
+ expect(within(table).queryByText(/Different baselines/)).not.toBeInTheDocument();
+ });
+
+ test('given only years in which both sides score zero then no related reforms show', () => {
+ const rows = [twoChildLimitClaim(2026, 'static', 0, 0)].map((row) => ({
+ ...row,
+ matchBasis: 'variable' as const,
+ }));
+ render();
+
+ expect(screen.queryByText('Related reforms')).not.toBeInTheDocument();
+ });
+
test('given several claims then one table holds them so their numbers line up', () => {
render(
{
await renderValidation();
const estimates = card('External estimates');
+ await userEvent.click(
+ within(estimates).getByRole('button', { name: 'Show 1 related reform score' })
+ );
expect(within(estimates).getByRole('table', { name: 'Related reforms' })).toBeInTheDocument();
expect(within(estimates).getByRole('button', { name: 'CTC extension' })).toBeInTheDocument();
expect(within(estimates).getByText('Joint Committee on Taxation')).toBeInTheDocument();
diff --git a/app/src/tests/unit/libs/flagship/calibrationMatching.test.ts b/app/src/tests/unit/libs/flagship/calibrationMatching.test.ts
index 0e743d44b..bae2c5d9c 100644
--- a/app/src/tests/unit/libs/flagship/calibrationMatching.test.ts
+++ b/app/src/tests/unit/libs/flagship/calibrationMatching.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import {
calibrationFitForGeography,
calibrationMatchesForPaths,
+ calibrationPreviewTargets,
calibrationTargetLabel,
calibrationValueKind,
calibrationVariableLabel,
@@ -25,6 +26,10 @@ import {
mockDcFitRows,
mockEitcCalibrationRow,
POPULACE_RELEASE_ID,
+ UC_BAND_ROWS,
+ UC_HOUSEHOLDS_ROW,
+ UC_SPENDING_ROW,
+ UC_TARGETS_BY_GAP,
} from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks';
import {
NATIONWIDE,
@@ -65,6 +70,31 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+describe('calibrationPreviewTargets', () => {
+ test('given a programme sorted by gap then its spending and caseload lead, then the largest gaps', () => {
+ // When
+ const { ordered, previewCount } = calibrationPreviewTargets(UC_TARGETS_BY_GAP);
+
+ // Then
+ expect(previewCount).toBe(6);
+ expect(ordered.slice(0, previewCount)).toEqual([
+ UC_SPENDING_ROW,
+ UC_HOUSEHOLDS_ROW,
+ ...UC_BAND_ROWS.slice(0, 4),
+ ]);
+ expect(ordered).toHaveLength(UC_TARGETS_BY_GAP.length);
+ });
+
+ test('given fewer targets than the preview then all show', () => {
+ // When
+ const { ordered, previewCount } = calibrationPreviewTargets([UC_HOUSEHOLDS_ROW]);
+
+ // Then
+ expect(ordered).toEqual([UC_HOUSEHOLDS_ROW]);
+ expect(previewCount).toBe(1);
+ });
+});
+
describe('geographyForRegion', () => {
test('given the country region then the national geography returns', () => {
expect(geographyForRegion('us')).toBe('US');
diff --git a/app/src/tests/unit/pages/flagship/Report.test.tsx b/app/src/tests/unit/pages/flagship/Report.test.tsx
index 90625cbf2..9d9dd3ea8 100644
--- a/app/src/tests/unit/pages/flagship/Report.test.tsx
+++ b/app/src/tests/unit/pages/flagship/Report.test.tsx
@@ -324,9 +324,10 @@ describe('FlagshipReportPage', () => {
await user.click(screen.getByRole('tab', { name: 'Validation' }));
const panel = screen.getByRole('tabpanel');
- expect(
- await within(panel).findByRole('table', { name: 'Related reforms' })
- ).toBeInTheDocument();
+ await user.click(
+ await within(panel).findByRole('button', { name: 'Show 1 related reform score' })
+ );
+ expect(within(panel).getByRole('table', { name: 'Related reforms' })).toBeInTheDocument();
expect(within(panel).getByRole('button', { name: 'CTC extension' })).toBeInTheDocument();
expect(
within(panel).queryByText('No external estimate is available for this reform.')