feat(destinations): types and secondary section flags

Destinations are secondary-only, so the flags go on computeSecondaryFlags
rather than computeSchoolFlags. A phase counts as present only when some
pupil group carries categories — an empty block would otherwise open a nav
entry pointing at a section that never renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
This commit is contained in:
TudorandClaude Opus 5 committed 2026-08-28 16:11:37 +01:00
1 parent c5719ef362
commit 68b6417149
3 files changed
+134 -1

No files matched your search

@@ -0,0 +1,84 @@
import { computeSecondaryFlags, buildSecondaryNavItems } from '@/lib/schoolSections';
import type { School, SchoolDestinations } from '@/lib/types';
const schoolInfo = {
urn: 137083, school_name: 'Northbrook Academy', phase: 'Secondary',
has_sixth_form: true,
} as unknown as School;
const base = { schoolInfo, yearlyData: [], deprivation: null, finance: null };
const phase = (categories = 1) => ({
cohort_year: '2022/23',
groups: {
all: {
cohort: 180,
categories: Array.from({ length: categories }, () => ({
category: 'school_sixth_form' as const,
pupils: 75, percentage: 41.7, status: 'published' as const,
})),
aggregates: {},
},
},
});
const ks4Only: SchoolDestinations = { ks4: phase(), ks5: null };
const both: SchoolDestinations = { ks4: phase(), ks5: phase() };
describe('computeSecondaryFlags — destinations', () => {
it('flags KS4 destinations when the block carries categories', () => {
const flags = computeSecondaryFlags({ ...base, destinations: ks4Only });
expect(flags.hasKs4Destinations).toBe(true);
expect(flags.hasKs5Destinations).toBe(false);
});
it('flags both phases when both are present', () => {
const flags = computeSecondaryFlags({ ...base, destinations: both });
expect(flags.hasKs4Destinations).toBe(true);
expect(flags.hasKs5Destinations).toBe(true);
});
it('flags neither when the block is absent', () => {
const flags = computeSecondaryFlags({ ...base, destinations: null });
expect(flags.hasKs4Destinations).toBe(false);
expect(flags.hasKs5Destinations).toBe(false);
});
it('does not flag a phase whose groups carry no categories', () => {
const empty: SchoolDestinations = {
ks4: { cohort_year: '2022/23', groups: {} }, ks5: null,
};
expect(computeSecondaryFlags({ ...base, destinations: empty }).hasKs4Destinations)
.toBe(false);
});
it('does not flag a phase whose only group has an empty category list', () => {
const empty: SchoolDestinations = { ks4: phase(0), ks5: null };
expect(computeSecondaryFlags({ ...base, destinations: empty }).hasKs4Destinations)
.toBe(false);
});
});
describe('buildSecondaryNavItems — destinations', () => {
const navInput = {
ofsted: null, admissions: null, admissionDistance: null,
hasLocation: false, yearlyDataLength: 0,
};
it('adds both entries, after GCSEs', () => {
const flags = computeSecondaryFlags({ ...base, destinations: both });
const ids = buildSecondaryNavItems({ ...flags, hasResults: true }, navInput)
.map(i => i.id);
expect(ids).toContain('destinations');
expect(ids).toContain('post16-destinations');
expect(ids.indexOf('destinations')).toBeGreaterThan(ids.indexOf('gcse'));
expect(ids.indexOf('post16-destinations')).toBe(ids.indexOf('destinations') + 1);
});
it('adds no entry for a phase that will not render — the nav must not link to a missing anchor', () => {
const flags = computeSecondaryFlags({ ...base, destinations: null });
const ids = buildSecondaryNavItems(flags, navInput).map(i => i.id);
expect(ids).not.toContain('destinations');
expect(ids).not.toContain('post16-destinations');
});
});
+23 -1
View File
@@ -10,6 +10,7 @@
import type {
School, SchoolResult, AbsenceData, SchoolCensus,
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
SchoolDestinations, DestinationPhase,
} from './types';
import { isSpecialSchool } from './utils';
@@ -20,6 +21,7 @@ export interface SchoolFlagsInput {
census: SchoolCensus | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
destinations?: SchoolDestinations | null;
}
export interface SchoolFlags {
@@ -183,10 +185,22 @@ export interface SecondaryFlags {
p8Suspended: boolean;
isSpecial: boolean;
suppressComparison: boolean;
/** Whether a destinations phase has anything to render. A block can exist
* with empty groups when the pipeline has run but the school has no rows,
* and a nav entry for a section that never renders links to nothing. */
hasKs4Destinations: boolean;
hasKs5Destinations: boolean;
}
/** A phase is renderable only if some pupil group actually carries categories. */
function phaseHasContent(phase: DestinationPhase | null | undefined): boolean {
if (!phase) return false;
return Object.values(phase.groups ?? {})
.some(group => (group?.categories?.length ?? 0) > 0);
}
export function computeSecondaryFlags({
schoolInfo, yearlyData, deprivation, finance,
schoolInfo, yearlyData, deprivation, finance, destinations,
}: Omit<SchoolFlagsInput, 'absenceData' | 'census'>): SecondaryFlags {
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
@@ -212,6 +226,8 @@ export function computeSecondaryFlags({
return {
latestResults, hasSixthForm, hasFinance, hasDeprivation, hasLocation,
hasWellbeing, hasResults: !!hasResults, p8Suspended, isSpecial, suppressComparison,
hasKs4Destinations: phaseHasContent(destinations?.ks4),
hasKs5Destinations: phaseHasContent(destinations?.ks5),
};
}
@@ -228,6 +244,12 @@ export function buildSecondaryNavItems(
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
// Destinations sit straight after attainment: they answer "and then what
// happened", which only makes sense once the results are in view.
if (flags.hasKs4Destinations) navItems.push({ id: 'destinations', label: 'After Year 11' });
if (flags.hasKs5Destinations) {
navItems.push({ id: 'post16-destinations', label: 'After sixth form' });
}
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (admissionDistance?.distance_m != null && hasLocation) {
navItems.push({ id: 'distance', label: 'Distance' });
+27
View File
@@ -598,3 +598,30 @@ export interface SortConfig {
key: string;
direction: SortDirection;
}
// ── Destination measures ────────────────────────────────────────────────────
// Shaped by backend/data_loader.py::_destinations_block. `status` is the field
// that matters: 'suppressed' is DfE withholding a figure it judged disclosive
// and must render as "withheld"; 'not_applicable' must render as nothing.
// `pupils` is null for both, so a null check alone loses the difference.
import type { DestinationCell, PupilGroup } from './destinations';
export interface DestinationGroupPayload {
cohort: number | null;
categories: DestinationCell[];
/** Totals DfE published itself. Never computed here — see lib/destinations.ts. */
aggregates: Partial<Record<'sustained_education' | 'sustained_all', DestinationCell>>;
}
export interface DestinationPhase {
/** e.g. "2022/23" — the section dates its own cohort, which runs about two
* GCSE years behind the results shown above it. */
cohort_year: string | null;
groups: Partial<Record<PupilGroup, DestinationGroupPayload>>;
}
export interface SchoolDestinations {
ks4: DestinationPhase | null;
ks5: DestinationPhase | null;
}