From 5044c24895f7962ca70272fa8cdeec68fb39a721 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sat, 1 Aug 2026 21:14:35 +0100 Subject: [PATCH] refactor(detail): extract derived flags and nav items to lib Pure data-shape logic moves out of the client components so page.tsx can compute the section list on the server without importing them. The secondary page is not a variant of the primary one -- different section ids (gcse, wellbeing), different flags, and History gated on more than one year rather than at least one -- so it gets its own computeSecondaryFlags and buildSecondaryNavItems rather than bending a shared function. Adds 16 unit tests covering the all-through and special-school branches, previously reachable only through a full component render. Co-Authored-By: Claude Opus 5 --- .../__tests__/lib/schoolSections.test.ts | 144 +++++++++++ nextjs-app/components/SchoolDetailView.tsx | 83 ++----- .../components/SecondarySchoolDetailView.tsx | 44 +--- nextjs-app/lib/schoolSections.ts | 226 ++++++++++++++++++ 4 files changed, 398 insertions(+), 99 deletions(-) create mode 100644 nextjs-app/__tests__/lib/schoolSections.test.ts create mode 100644 nextjs-app/lib/schoolSections.ts diff --git a/nextjs-app/__tests__/lib/schoolSections.test.ts b/nextjs-app/__tests__/lib/schoolSections.test.ts new file mode 100644 index 0000000..0c893d7 --- /dev/null +++ b/nextjs-app/__tests__/lib/schoolSections.test.ts @@ -0,0 +1,144 @@ +import { + computeSchoolFlags, buildNavItems, + computeSecondaryFlags, buildSecondaryNavItems, +} from '@/lib/schoolSections'; +import { + primaryFixture, secondaryFixture, allThroughFixture, specialFixture, +} from '../support/schoolFixtures'; + +describe('computeSchoolFlags', () => { + it('treats an all-through school as both primary-capable and secondary', () => { + const f = computeSchoolFlags(allThroughFixture); + expect(f.isAllThrough).toBe(true); + expect(f.isSecondary).toBe(true); + expect(f.hasKS2Results).toBe(true); + expect(f.hasKS4Results).toBe(true); + }); + + it('classifies an ordinary primary', () => { + const f = computeSchoolFlags(primaryFixture); + expect(f.isPrimary).toBe(true); + expect(f.isSecondary).toBe(false); + expect(f.hasKS2Results).toBe(true); + expect(f.hasKS4Results).toBe(false); + }); + + it('classifies an ordinary secondary', () => { + const f = computeSchoolFlags(secondaryFixture); + expect(f.isSecondary).toBe(true); + expect(f.isAllThrough).toBe(false); + expect(f.hasKS4Results).toBe(true); + }); + + it('suppresses the KS2 comparison for a special school', () => { + const f = computeSchoolFlags(specialFixture); + expect(f.isSpecial).toBe(true); + expect(f.suppressKs2Comparison).toBe(true); + expect(f.suppressKs4Comparison).toBe(true); + }); + + it('treats an all-zero KS2 row as a placeholder', () => { + const f = computeSchoolFlags(specialFixture); + expect(f.ks2Placeholder).toBe(true); + }); + + it('does not suppress comparison for an ordinary primary', () => { + const f = computeSchoolFlags(primaryFixture); + expect(f.isSpecial).toBe(false); + expect(f.suppressKs2Comparison).toBe(false); + }); + + it('detects a gender split only when census counts are present', () => { + expect(computeSchoolFlags(primaryFixture).hasGenderSplit).toBe(true); + expect( + computeSchoolFlags({ ...primaryFixture, census: null }).hasGenderSplit, + ).toBe(false); + }); +}); + +describe('buildNavItems', () => { + it('omits sections with no data', () => { + const flags = computeSchoolFlags(specialFixture); + const ids = buildNavItems(flags, { + ofsted: null, admissions: null, yearlyDataLength: 1, + }).map((n) => n.id); + + expect(ids).not.toContain('ofsted'); + expect(ids).not.toContain('admissions'); + expect(ids).toContain('history'); + }); + + it('labels the results section by phase', () => { + const label = (fixture: any) => { + const flags = computeSchoolFlags(fixture); + return buildNavItems(flags, { + ofsted: fixture.ofsted, + admissions: fixture.admissions, + yearlyDataLength: fixture.yearlyData.length, + }).find((n) => n.id === 'results')?.label; + }; + + expect(label(primaryFixture)).toBe('SATs'); + expect(label(secondaryFixture)).toBe('GCSEs'); + expect(label(allThroughFixture)).toBe('Results'); + }); + + it('keeps the engagement-led ordering', () => { + const flags = computeSchoolFlags(primaryFixture); + const ids = buildNavItems(flags, { + ofsted: primaryFixture.ofsted, + admissions: primaryFixture.admissions, + yearlyDataLength: primaryFixture.yearlyData.length, + }).map((n) => n.id); + + expect(ids).toEqual([ + 'ofsted', 'results', 'admissions', 'inclusion', + 'history', 'school-life', 'local-area', 'finances', + ]); + }); +}); + +describe('computeSecondaryFlags', () => { + it('reads results and sixth form from the secondary fixture', () => { + const f = computeSecondaryFlags(secondaryFixture); + expect(f.hasResults).toBe(true); + expect(f.hasSixthForm).toBe(false); + expect(f.hasWellbeing).toBe(true); + }); + + it('reports sixth form when GIAS flags it', () => { + expect(computeSecondaryFlags(allThroughFixture).hasSixthForm).toBe(true); + }); + + it('suppresses the comparison for a special school', () => { + const f = computeSecondaryFlags(specialFixture); + expect(f.isSpecial).toBe(true); + expect(f.suppressComparison).toBe(true); + }); + + it('does not flag Progress 8 as suspended for pre-2024/25 cohorts', () => { + expect(computeSecondaryFlags(secondaryFixture).p8Suspended).toBe(false); + }); +}); + +describe('buildSecondaryNavItems', () => { + it('uses the secondary section ids', () => { + const flags = computeSecondaryFlags(secondaryFixture); + const ids = buildSecondaryNavItems(flags, { + ofsted: secondaryFixture.ofsted, + admissions: secondaryFixture.admissions, + yearlyDataLength: secondaryFixture.yearlyData.length, + }).map((n) => n.id); + + expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']); + }); + + it('gates History on more than one year, unlike the primary page', () => { + const flags = computeSecondaryFlags(secondaryFixture); + const ids = buildSecondaryNavItems(flags, { + ofsted: null, admissions: null, yearlyDataLength: 1, + }).map((n) => n.id); + + expect(ids).not.toContain('history'); + }); +}); diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index c14b67a..cb5da31 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -18,8 +18,9 @@ import type { SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { - formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool, + formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, } from '@/lib/utils'; +import { computeSchoolFlags, buildNavItems } from '@/lib/schoolSections'; import { DeltaChip } from './DeltaChip'; import { SpecialSchoolNote } from './SpecialSchoolNote'; import { summariseAdmissions } from '@/lib/compareLogic'; @@ -162,16 +163,18 @@ export function SchoolDetailView({ return () => window.removeEventListener('keydown', onKey); }, [sectionsOpen]); - const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; - - // Phase detection. All-through schools cover BOTH key stages, so they are - // neither "pure primary" nor "pure secondary": isSecondary stays true (they - // have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs, - // KS2 trend) back on and switches phase-specific copy to an all-ages framing. + // Derived data-shape logic lives in lib/schoolSections so the server route + // can compute the section list without importing this client component. + const flags = computeSchoolFlags({ + schoolInfo, yearlyData, absenceData, census, deprivation, finance, + }); + const { + latestResults, isAllThrough, isSecondary, isPrimary, + hasGenderSplit, hasInclusionData, hasSchoolLife, hasDeprivation, + hasFinance, hasLocation, hasKS2Results, hasKS4Results, hasAnyResults, + isSpecial, ks2Placeholder, suppressKs2Comparison, suppressKs4Comparison, + } = flags; const phase = schoolInfo.phase ?? ''; - const isAllThrough = phase.toLowerCase() === 'all-through'; - const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough; - const isPrimary = !isSecondary; const primaryAvg = nationalAvg?.primary ?? {}; const secondaryAvg = nationalAvg?.secondary ?? {}; @@ -203,63 +206,9 @@ export function SchoolDetailView({ return `This school is in one of England's less deprived areas (decile ${decile}/10).`; }; - // Gender split availability (only meaningful for Mixed schools with census data) - const isMixedSchool = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null; - const hasGenderSplit = isMixedSchool - && census?.female_pupils != null - && census?.male_pupils != null - && (census.female_pupils + census.male_pupils) > 0; - - // Guard for Pupils & Inclusion — only show if at least one metric is available - const hasInclusionData = (latestResults?.disadvantaged_pct != null) - || (latestResults?.eal_pct != null) - || (latestResults?.sen_support_pct != null) - || hasGenderSplit; - - const hasSchoolLife = absenceData != null; - const hasDeprivation = deprivation != null && deprivation.idaci_decile != null; - const hasFinance = finance != null && finance.per_pupil_spend != null; - const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; - - // Determine whether this school has KS2 or KS4 results to show - const hasKS2Results = latestResults != null && latestResults.rwm_expected_pct != null; - const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null; - const hasAnyResults = hasKS2Results || hasKS4Results; - - // Special schools / PRUs / AP: their pupils sit the same tests but very few - // reach the mainstream "expected standard", so a 0% headline and an England - // comparison portray them as failing against a benchmark that doesn't fit. - const isSpecial = isSpecialSchool(schoolInfo); - // Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a - // special/suppressed signature) is a placeholder, not a real result. This - // needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0% - // combined (some pupils met individual subjects but not all three) stays - // comparable. Attainment 8 is a single 0–80 score with no subject breakdown - // to form such a signature, so KS4 keys off establishment type only — a - // genuine (if extreme) 0.0 still shows its real figure and comparison. - const ks2Placeholder = latestResults != null - && latestResults.rwm_expected_pct === 0 - && (latestResults.reading_expected_pct ?? 0) === 0 - && (latestResults.writing_expected_pct ?? 0) === 0 - && (latestResults.maths_expected_pct ?? 0) === 0; - // Whether to drop the England-average deltas / national markers / "below" - // framing on the attainment measures. - const suppressKs2Comparison = isSpecial || ks2Placeholder; - const suppressKs4Comparison = isSpecial; - - // Build section nav items dynamically — only sections with data. - // Order is engagement-led (from section_nav_used analytics): the most-sought - // sections — results, admissions, inclusion, history — sit near the top, - // after the recognised Ofsted badge; low-demand context sections stay last. - const navItems: { id: string; label: string }[] = []; - if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); - if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' }); - if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); - if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); - if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' }); - if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); - if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' }); - if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); + const navItems = buildNavItems(flags, { + ofsted, admissions, yearlyDataLength: yearlyData.length, + }); // Track active section as user scrolls useEffect(() => { diff --git a/nextjs-app/components/SecondarySchoolDetailView.tsx b/nextjs-app/components/SecondarySchoolDetailView.tsx index 2f1a537..ea6a174 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.tsx +++ b/nextjs-app/components/SecondarySchoolDetailView.tsx @@ -23,7 +23,8 @@ import type { SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; -import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils'; +import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas } from '@/lib/utils'; +import { computeSecondaryFlags, buildSecondaryNavItems } from '@/lib/schoolSections'; import { DeltaChip } from './DeltaChip'; import { SpecialSchoolNote } from './SpecialSchoolNote'; import { track, getNavigationSource } from '@/lib/analytics'; @@ -90,29 +91,16 @@ export function SecondarySchoolDetailView({ // Header details collapse behind a "Show all details" link on mobile/tablet. const [detailsOpen, setDetailsOpen] = useState(false); - const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; + // Derived data-shape logic lives in lib/schoolSections so the server route + // can compute the section list without importing this client component. + const flags = computeSecondaryFlags({ schoolInfo, yearlyData, deprivation, finance }); + const { + latestResults, hasSixthForm, hasFinance, hasDeprivation, hasLocation, + hasWellbeing, hasResults, p8Suspended, isSpecial, suppressComparison, + } = flags; const secondaryAvg = nationalAvg?.secondary ?? {}; - // GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false. - const hasSixthForm = schoolInfo.has_sixth_form ?? false; - const hasFinance = finance != null && finance.per_pupil_spend != null; - const hasDeprivation = deprivation != null && deprivation.idaci_decile != null; - const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; - const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation; - const p8Suspended = latestResults != null && latestResults.year >= 202425; - const hasResults = latestResults?.attainment_8_score != null; - - // Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND, - // so their headline attainment is far below the mainstream average by design. - // Drop the England comparison + "below" framing so the page doesn't portray - // them as failing against a benchmark that doesn't fit. Attainment 8 is a - // single 0–80 score with no subject breakdown to test for a placeholder, so - // this keys off establishment type only — a genuine (if extreme) 0.0 at a - // mainstream school still shows its real value and comparison. - const isSpecial = isSpecialSchool(schoolInfo); - const suppressComparison = isSpecial; - const admissionsTag = (() => { const policy = schoolInfo.admissions_policy?.toLowerCase() ?? ''; if (policy.includes('selective')) return 'Selective'; @@ -155,17 +143,9 @@ export function SecondarySchoolDetailView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [schoolInfo.urn]); - // Build nav items dynamically based on available data. - // Engagement-led order (matches the primary page): recognised Ofsted badge, - // then the most-sought sections — results, admissions, history — with the - // experience and context sections following. - const navItems: { id: string; label: string }[] = []; - if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); - if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' }); - if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); - if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' }); - if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' }); - if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); + const navItems = buildSecondaryNavItems(flags, { + ofsted, admissions, yearlyDataLength: yearlyData.length, + }); // Track active section as user scrolls useEffect(() => { diff --git a/nextjs-app/lib/schoolSections.ts b/nextjs-app/lib/schoolSections.ts new file mode 100644 index 0000000..e11b614 --- /dev/null +++ b/nextjs-app/lib/schoolSections.ts @@ -0,0 +1,226 @@ +/** + * Derived data-shape logic for the school detail pages. + * + * Pure functions with no React dependency, so the server route can decide + * which sections exist without pulling in a client component. Extracted from + * SchoolDetailView, which owned this logic inline while it was a client + * component. + */ + +import type { + School, SchoolResult, AbsenceData, SchoolCensus, + OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance, +} from './types'; +import { isSpecialSchool } from './utils'; + +export interface SchoolFlagsInput { + schoolInfo: School; + yearlyData: SchoolResult[]; + absenceData: AbsenceData | null; + census: SchoolCensus | null; + deprivation: SchoolDeprivation | null; + finance: SchoolFinance | null; +} + +export interface SchoolFlags { + latestResults: SchoolResult | null; + isAllThrough: boolean; + isSecondary: boolean; + isPrimary: boolean; + hasGenderSplit: boolean; + hasInclusionData: boolean; + hasSchoolLife: boolean; + hasDeprivation: boolean; + hasFinance: boolean; + hasLocation: boolean; + hasKS2Results: boolean; + hasKS4Results: boolean; + hasAnyResults: boolean; + isSpecial: boolean; + ks2Placeholder: boolean; + suppressKs2Comparison: boolean; + suppressKs4Comparison: boolean; +} + +export interface NavItem { + id: string; + label: string; +} + +export function computeSchoolFlags({ + schoolInfo, yearlyData, absenceData, census, deprivation, finance, +}: SchoolFlagsInput): SchoolFlags { + const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; + + // Phase detection. All-through schools cover BOTH key stages, so they are + // neither "pure primary" nor "pure secondary": isSecondary stays true (they + // have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs, + // KS2 trend) back on and switches phase-specific copy to an all-ages framing. + const phase = schoolInfo.phase ?? ''; + const isAllThrough = phase.toLowerCase() === 'all-through'; + const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough; + const isPrimary = !isSecondary; + + // Gender split availability (only meaningful for Mixed schools with census data) + const isMixedSchool = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null; + const hasGenderSplit = !!(isMixedSchool + && census?.female_pupils != null + && census?.male_pupils != null + && (census.female_pupils + census.male_pupils) > 0); + + // Guard for Pupils & Inclusion — only show if at least one metric is available + const hasInclusionData = (latestResults?.disadvantaged_pct != null) + || (latestResults?.eal_pct != null) + || (latestResults?.sen_support_pct != null) + || hasGenderSplit; + + const hasSchoolLife = absenceData != null; + const hasDeprivation = deprivation != null && deprivation.idaci_decile != null; + const hasFinance = finance != null && finance.per_pupil_spend != null; + const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; + + // Determine whether this school has KS2 or KS4 results to show + const hasKS2Results = latestResults != null && latestResults.rwm_expected_pct != null; + const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null; + const hasAnyResults = hasKS2Results || hasKS4Results; + + // Special schools / PRUs / AP: their pupils sit the same tests but very few + // reach the mainstream "expected standard", so a 0% headline and an England + // comparison portray them as failing against a benchmark that doesn't fit. + const isSpecial = isSpecialSchool(schoolInfo); + // Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a + // special/suppressed signature) is a placeholder, not a real result. This + // needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0% + // combined (some pupils met individual subjects but not all three) stays + // comparable. Attainment 8 is a single 0–80 score with no subject breakdown + // to form such a signature, so KS4 keys off establishment type only — a + // genuine (if extreme) 0.0 still shows its real figure and comparison. + const ks2Placeholder = latestResults != null + && latestResults.rwm_expected_pct === 0 + && (latestResults.reading_expected_pct ?? 0) === 0 + && (latestResults.writing_expected_pct ?? 0) === 0 + && (latestResults.maths_expected_pct ?? 0) === 0; + // Whether to drop the England-average deltas / national markers / "below" + // framing on the attainment measures. + const suppressKs2Comparison = isSpecial || ks2Placeholder; + const suppressKs4Comparison = isSpecial; + + return { + latestResults, + isAllThrough, isSecondary, isPrimary, + hasGenderSplit, hasInclusionData, hasSchoolLife, + hasDeprivation, hasFinance, hasLocation, + hasKS2Results, hasKS4Results, hasAnyResults, + isSpecial, ks2Placeholder, + suppressKs2Comparison, suppressKs4Comparison, + }; +} + +export interface NavItemsInput { + ofsted: OfstedInspection | null; + admissions: SchoolAdmissions | null; + yearlyDataLength: number; +} + +/** + * Build section nav items dynamically — only sections with data. + * Order is engagement-led (from section_nav_used analytics): the most-sought + * sections — results, admissions, inclusion, history — sit near the top, + * after the recognised Ofsted badge; low-demand context sections stay last. + * + * These conditions MUST match the conditions the section composers use to + * render, or the nav will link to sections that do not exist. + */ +export function buildNavItems( + flags: SchoolFlags, + { ofsted, admissions, yearlyDataLength }: NavItemsInput, +): NavItem[] { + const navItems: NavItem[] = []; + if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); + if (flags.hasAnyResults) { + navItems.push({ + id: 'results', + label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs', + }); + } + if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); + if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); + if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' }); + if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); + if (flags.hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' }); + if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); + return navItems; +} + +// --------------------------------------------------------------------------- +// Secondary pages +// +// The secondary view is not a variant of the primary one: it has its own +// section ids ('gcse', 'wellbeing'), its own flags, and gates History on +// MORE THAN ONE year rather than at least one. Kept as separate functions so +// neither phase's behaviour bends to accommodate the other. +// --------------------------------------------------------------------------- + +export interface SecondaryFlags { + latestResults: SchoolResult | null; + hasSixthForm: boolean; + hasFinance: boolean; + hasDeprivation: boolean; + hasLocation: boolean; + hasWellbeing: boolean; + hasResults: boolean; + /** Progress 8 was suspended from the 2024/25 cohort onwards. */ + p8Suspended: boolean; + isSpecial: boolean; + suppressComparison: boolean; +} + +export function computeSecondaryFlags({ + schoolInfo, yearlyData, deprivation, finance, +}: Omit): SecondaryFlags { + const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; + + // GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false. + const hasSixthForm = schoolInfo.has_sixth_form ?? false; + const hasFinance = finance != null && finance.per_pupil_spend != null; + const hasDeprivation = deprivation != null && deprivation.idaci_decile != null; + const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; + const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation; + const p8Suspended = latestResults != null && latestResults.year >= 202425; + const hasResults = latestResults?.attainment_8_score != null; + + // Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND, + // so their headline attainment is far below the mainstream average by design. + // Drop the England comparison + "below" framing so the page doesn't portray + // them as failing against a benchmark that doesn't fit. Attainment 8 is a + // single 0–80 score with no subject breakdown to test for a placeholder, so + // this keys off establishment type only — a genuine (if extreme) 0.0 at a + // mainstream school still shows its real value and comparison. + const isSpecial = isSpecialSchool(schoolInfo); + const suppressComparison = isSpecial; + + return { + latestResults, hasSixthForm, hasFinance, hasDeprivation, hasLocation, + hasWellbeing, hasResults: !!hasResults, p8Suspended, isSpecial, suppressComparison, + }; +} + +/** + * Build nav items for a secondary page. + * Engagement-led order (matches the primary page): recognised Ofsted badge, + * then the most-sought sections — results, admissions, history — with the + * experience and context sections following. + */ +export function buildSecondaryNavItems( + flags: SecondaryFlags, + { ofsted, admissions, yearlyDataLength }: NavItemsInput, +): NavItem[] { + const navItems: NavItem[] = []; + if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); + if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' }); + if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); + if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' }); + if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' }); + if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); + return navItems; +}