2026-08-28 15:57:46 +01:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-08-28 19:58:02 +01:00
|
|
|
* 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.
|
2026-08-28 15:57:46 +01:00
|
|
|
*
|
|
|
|
|
* 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<CardGroup, DestinationCategory[]> = {
|
|
|
|
|
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<BarSegment>(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<DestinationCategory, string> = {
|
|
|
|
|
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<CardGroup, { question: string; hint: string }> = {
|
|
|
|
|
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;
|
|
|
|
|
}
|