The destination categories sum to the cohort and DfE publishes the cohort total, so a lone suppressed cell is recoverable by subtraction. canAggregate, canRenderPublishedAggregate and canRenderBar are what stop a consumer doing that; toBarSegments throws rather than leaving a readable gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
156 lines
5.6 KiB
TypeScript
156 lines
5.6 KiB
TypeScript
/**
|
|
* 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 below are what stop this module's
|
|
* consumers doing that by accident, and they are why a percentage is never
|
|
* reconstructed from a partial sum.
|
|
*
|
|
* 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[];
|
|
/** Aggregates DfE published itself, keyed by slug. */
|
|
aggregates: Partial<Record<'sustained_education' | 'sustained_all', 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 };
|
|
}
|
|
|
|
/**
|
|
* R2, the other direction: DfE published this total itself. Showing it beside
|
|
* the components is safe only when it spans no suppressed component, or two or
|
|
* more. Exactly one, and the total names the withheld figure.
|
|
*/
|
|
export function canRenderPublishedAggregate(components: DestinationCell[]): boolean {
|
|
return suppressedCount(components) !== 1;
|
|
}
|
|
|
|
/** 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;
|
|
}
|