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
56 changes: 48 additions & 8 deletions app/src/components/flagship/CalibrationMatches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
calibrationFitForGeography,
CalibrationMatches,
calibrationMatchesForPaths,
calibrationPreviewTargets,
CalibrationRing,
calibrationTargetLabel,
CalibrationTargetRow,
Expand Down Expand Up @@ -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) => (
<tr key={target.name}>
<th scope="row" style={labelCellStyle}>
<TargetDetails target={target} group={group} countryId={countryId} />
</th>
<TargetCells target={target} />
</tr>
))}
{ordered.length > previewCount && (
<tr>
<td colSpan={CALIBRATION_COLUMNS.length} style={{ padding: `${spacing.xs} 0` }}>
<button
type="button"
style={rowButtonStyle}
aria-expanded={showAll}
onClick={() => setShowAll((value) => !value)}
>
{showAll
? `Show the first ${previewCount} ${group} targets`
: `Show all ${ordered.length} ${group} targets`}
</button>
</td>
</tr>
)}
</>
);
}

export function CalibrationMatchSection({
matches,
pin,
Expand Down Expand Up @@ -502,14 +549,7 @@ export function CalibrationMatchSection({
</CellNote>
</th>
</tr>
{match.targets.map((target) => (
<tr key={target.name}>
<th scope="row" style={labelCellStyle}>
<TargetDetails target={target} group={group} countryId={countryId} />
</th>
<TargetCells target={target} />
</tr>
))}
<CalibrationGroupTargets targets={match.targets} group={group} countryId={countryId} />
</tbody>
);
})}
Expand Down
86 changes: 69 additions & 17 deletions app/src/components/flagship/ScorecardComparisons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
claimPeriod,
claimValue,
focusRelatedReformClaims,
type RelatedScorecardClaim,
type ScorecardComparisonsResult,
} from '@/libs/flagship/scorecardComparisons';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<>
<tr style={rowStyle}>
Expand Down Expand Up @@ -644,6 +654,7 @@ export function ScorecardClaimTable({
key={entry.row.claim_id}
row={entry.row}
presentation={entry.presentation}
related={entry.related}
/>
))}
</EvidenceTable>
Expand All @@ -653,35 +664,76 @@ 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 (
<Stack style={{ gap: spacing.sm }}>
<ValidationSubheading>Related reforms</ValidationSubheading>
<ValidationNote>
Published scores of policies that change the same rules. They do not score this exact
reform.
</ValidationNote>
<ScorecardClaimTable label="Related reforms" entries={visible.map((row) => ({ row }))} />
{ordered.length > RELATED_REFORM_PREVIEW && (
<button
type="button"
style={{ ...rowButtonStyle, alignSelf: 'flex-start' }}
aria-expanded={showAll}
onClick={() => setShowAll((value) => !value)}
>
{showAll
? `Show the first ${RELATED_REFORM_PREVIEW} related reforms`
: `Show all ${ordered.length} related reforms`}
</button>
<button
type="button"
style={toggleStyle}
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
{open
? 'Hide related reforms'
: `Show ${ordered.length} related reform ${ordered.length === 1 ? 'score' : 'scores'}`}
</button>
{open && (
<>
<ScorecardClaimTable
label="Related reforms"
entries={visible.map((row) => ({
row,
presentation: relatedReformPresentation(row),
related: true,
}))}
/>
{ordered.length > RELATED_REFORM_PREVIEW && (
<button
type="button"
style={toggleStyle}
aria-expanded={showAll}
onClick={() => setShowAll((value) => !value)}
>
{showAll
? `Show the first ${RELATED_REFORM_PREVIEW} related reforms`
: `Show all ${ordered.length} related reforms`}
</button>
)}
</>
)}
</Stack>
);
Expand Down
27 changes: 27 additions & 0 deletions app/src/libs/flagship/calibrationMatching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down
32 changes: 32 additions & 0 deletions app/src/libs/flagship/scorecardComparisons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, RelatedScorecardClaim>();
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[];
Expand Down
35 changes: 35 additions & 0 deletions app/src/tests/fixtures/libs/flagship/calibrationMatchingMocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
28 changes: 28 additions & 0 deletions app/src/tests/fixtures/libs/flagship/scorecardComparisonMocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,31 @@ export const ukMeasureClaim = (overrides: Partial<ScorecardClaim> = {}): 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',
},
}),
});
Loading
Loading