/** * Destination measures — categories, the card grouping, and the disclosure * guards. * * DfE suppresses individual cells with `c`, and the destination categories sum * to the cohort. So subtracting the published cells from the cohort total * recovers a lone suppressed cell exactly — which is the case on 22% of * mainstream secondaries. * * The guards here are the SECOND line of defence, not the first. Not drawing a * number does nothing to stop it being computed, so the real fix lives in * backend/data_loader.py::_mask_for_disclosure, which withholds a companion * cell before the figures ever leave the server. These functions keep the UI * honest about what it draws from an already-safe payload. * * See docs/superpowers/specs/2026-08-28-destination-measures-design.md. */ export type DestinationCategory = | 'school_sixth_form' | 'sixth_form_college' | 'further_education' | 'other_education' | 'apprenticeship' | 'employment' | 'not_sustained' | 'not_captured' // 16-18 only. | 'higher_education'; export type PupilGroup = 'all' | 'disadvantaged' | 'other'; export type DestinationStatus = 'published' | 'suppressed' | 'not_applicable'; export type CardGroup = 'academic' | 'college' | 'work'; export interface DestinationCell { category: DestinationCategory; pupils: number | null; percentage: number | null; status: DestinationStatus; } export interface DestinationGroup { cohort: number; cells: DestinationCell[]; } /** Display order, which is also bar order: education, then work, then absence. */ export const CATEGORY_ORDER: DestinationCategory[] = [ 'higher_education', 'school_sixth_form', 'sixth_form_college', 'further_education', 'other_education', 'apprenticeship', 'employment', 'not_sustained', 'not_captured', ]; /** * Our grouping, not DfE's — the single most arguable thing on the page, which * is why it lives in exactly one place. `not_sustained` and `not_captured` are * deliberately absent: they are the absence of a destination, not a route, and * "activity not captured" includes independent schools and moving abroad. */ export const CARD_GROUPS: Record = { academic: ['higher_education', 'school_sixth_form', 'sixth_form_college'], college: ['further_education', 'other_education'], work: ['apprenticeship', 'employment'], }; export function suppressedCount(cells: DestinationCell[]): number { return cells.filter(c => c.status === 'suppressed').length; } /** R2: a sum computed from components is safe only if every component is published. */ export function canAggregate(cells: DestinationCell[]): boolean { return cells.length > 0 && cells.every(c => c.status === 'published'); } export function aggregateCells( cells: DestinationCell[], cohort: number, ): { pupils: number; percentage: number } | null { if (!canAggregate(cells) || cohort <= 0) return null; const pupils = cells.reduce((sum, c) => sum + (c.pupils ?? 0), 0); return { pupils, percentage: (pupils / cohort) * 100 }; } /** R1: a bar is drawable only when nothing in the group is withheld. */ export function canRenderBar(group: DestinationGroup): boolean { return group.cohort > 0 && group.cells.every(c => c.status === 'published'); } export interface BarSegment { category: DestinationCategory; pupils: number; /** Exact width from the count — never the rounded percentage. */ widthPct: number; /** Rounded value for the segment label. */ labelPct: number; } export function toBarSegments(group: DestinationGroup): BarSegment[] { if (!canRenderBar(group)) { throw new Error( 'toBarSegments: refusing to draw a bar for a group with suppressed categories — ' + 'the gap left behind would disclose the withheld figure (R1).', ); } const byCategory = new Map(group.cells.map(c => [c.category, c])); return CATEGORY_ORDER.flatMap(category => { const cell = byCategory.get(category); if (!cell || cell.pupils === null) return []; const widthPct = (cell.pupils / group.cohort) * 100; return [{ category, pupils: cell.pupils, widthPct, labelPct: Math.round(widthPct) }]; }); } export const CATEGORY_LABELS: Record = { higher_education: 'UK higher education', school_sixth_form: 'State-funded school sixth form', sixth_form_college: 'Sixth-form college', further_education: 'FE and other colleges', other_education: 'Other education destination', apprenticeship: 'Apprenticeship', employment: 'Employment', not_sustained: 'Not recorded as a sustained destination', not_captured: 'Activity not captured', }; export const CARD_QUESTIONS: Record = { academic: { question: 'Do leavers stay on an academic route?', hint: 'a school sixth form or a sixth-form college', }, college: { question: 'Or move to a college?', hint: 'an FE or other college', }, work: { question: 'Or straight into work?', hint: 'an apprenticeship or a job', }, }; /** Which card a category belongs to, or null for the two absence categories. */ export function cardGroupFor(category: DestinationCategory): CardGroup | null { for (const [group, categories] of Object.entries(CARD_GROUPS) as [CardGroup, DestinationCategory[]][]) { if (categories.includes(category)) return group; } return null; }