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 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,8 +18,9 @@ import type {
|
|||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
|
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
|
import { computeSchoolFlags, buildNavItems } from '@/lib/schoolSections';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
@@ -162,16 +163,18 @@ export function SchoolDetailView({
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [sectionsOpen]);
|
}, [sectionsOpen]);
|
||||||
|
|
||||||
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.
|
||||||
// Phase detection. All-through schools cover BOTH key stages, so they are
|
const flags = computeSchoolFlags({
|
||||||
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
schoolInfo, yearlyData, absenceData, census, deprivation, finance,
|
||||||
// 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 {
|
||||||
|
latestResults, isAllThrough, isSecondary, isPrimary,
|
||||||
|
hasGenderSplit, hasInclusionData, hasSchoolLife, hasDeprivation,
|
||||||
|
hasFinance, hasLocation, hasKS2Results, hasKS4Results, hasAnyResults,
|
||||||
|
isSpecial, ks2Placeholder, suppressKs2Comparison, suppressKs4Comparison,
|
||||||
|
} = flags;
|
||||||
const phase = schoolInfo.phase ?? '';
|
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 primaryAvg = nationalAvg?.primary ?? {};
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
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).`;
|
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 navItems = buildNavItems(flags, {
|
||||||
const isMixedSchool = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
ofsted, admissions, yearlyDataLength: yearlyData.length,
|
||||||
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' });
|
|
||||||
|
|
||||||
// Track active section as user scrolls
|
// Track active section as user scrolls
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import type {
|
|||||||
SchoolAdmissions,
|
SchoolAdmissions,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} 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 { DeltaChip } from './DeltaChip';
|
||||||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||||
import { track, getNavigationSource } from '@/lib/analytics';
|
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.
|
// Header details collapse behind a "Show all details" link on mobile/tablet.
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
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 ?? {};
|
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 admissionsTag = (() => {
|
||||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||||
if (policy.includes('selective')) return 'Selective';
|
if (policy.includes('selective')) return 'Selective';
|
||||||
@@ -155,17 +143,9 @@ export function SecondarySchoolDetailView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [schoolInfo.urn]);
|
}, [schoolInfo.urn]);
|
||||||
|
|
||||||
// Build nav items dynamically based on available data.
|
const navItems = buildSecondaryNavItems(flags, {
|
||||||
// Engagement-led order (matches the primary page): recognised Ofsted badge,
|
ofsted, admissions, yearlyDataLength: yearlyData.length,
|
||||||
// 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' });
|
|
||||||
|
|
||||||
// Track active section as user scrolls
|
// Track active section as user scrolls
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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<SchoolFlagsInput, 'absenceData' | 'census'>): 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user