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:
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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user