diff --git a/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts b/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts new file mode 100644 index 0000000..670ec07 --- /dev/null +++ b/nextjs-app/__tests__/lib/schoolSections.destinations.test.ts @@ -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'); + }); +}); diff --git a/nextjs-app/lib/schoolSections.ts b/nextjs-app/lib/schoolSections.ts index de7b9c4..eeec922 100644 --- a/nextjs-app/lib/schoolSections.ts +++ b/nextjs-app/lib/schoolSections.ts @@ -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): 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' }); diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 601fd1f..ded1b99 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -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>; +} + +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>; +} + +export interface SchoolDestinations { + ks4: DestinationPhase | null; + ks5: DestinationPhase | null; +}