/** * SchoolDetailView Component * Displays comprehensive school information with performance charts */ 'use client'; import { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; import { useComparison } from '@/hooks/useComparison'; import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap'; import { MetricTooltip } from './MetricTooltip'; import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, SchoolAdmissions, SenDetail, Phonics, SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool, } from '@/lib/utils'; import { DeltaChip } from './DeltaChip'; import { SpecialSchoolNote } from './SpecialSchoolNote'; import { summariseAdmissions } from '@/lib/compareLogic'; const PerformanceChart = dynamic( () => import('./PerformanceChart').then((m) => m.PerformanceChart), { ssr: false }, ); const SatsChart = dynamic(() => import('./SatsChart'), { ssr: false }); const AdmissionsTrendChart = dynamic(() => import('./AdmissionsTrendChart'), { ssr: false }); import { track, getNavigationSource } from '@/lib/analytics'; import styles from './SchoolDetailView.module.css'; const OFSTED_LABELS: Record = { 1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate', }; const RC_LABELS: Record = { 1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement', }; const RC_CATEGORIES = [ { key: 'rc_inclusion' as const, label: 'Inclusion' }, { key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' }, { key: 'rc_achievement' as const, label: 'Achievement' }, { key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' }, { key: 'rc_personal_development' as const, label: 'Personal Development' }, { key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' }, { key: 'rc_early_years' as const, label: 'Early Years' }, { key: 'rc_sixth_form' as const, label: 'Sixth Form' }, ]; function progressClass(val: number | null | undefined): string { if (val == null) return ''; if (val > 0) return styles.progressPositive; if (val < 0) return styles.progressNegative; return ''; } interface SchoolDetailViewProps { schoolInfo: School; yearlyData: SchoolResult[]; absenceData: AbsenceData | null; ofsted: OfstedInspection | null; census: SchoolCensus | null; admissions: SchoolAdmissions | null; admissionsHistory: SchoolAdmissions[]; senDetail: SenDetail | null; phonics: Phonics | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; } export function SchoolDetailView({ schoolInfo, yearlyData, absenceData, ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance, }: SchoolDetailViewProps) { const router = useRouter(); const { addSchool, removeSchool, isSelected } = useComparison(); const isInComparison = isSelected(schoolInfo.urn); const [activeSection, setActiveSection] = useState(''); const [admissionsView, setAdmissionsView] = useState<'year' | 'trend'>('year'); // Trend toggle only appears with ≥2 years carrying an offer rate. const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length; const showAdmissionsTrend = admissionsOfferYears >= 2; // Banded interpretation of the first-choice offer rate ("More than half of // first choices missed out" etc.) — the same banding the compare screen // uses, so a low offer rate reads as how severe it actually is. const admissionsSummary = summariseAdmissions(admissions); // Only the section links scroll horizontally; Back and "All" stay pinned. const sectionLinksRef = useRef(null); const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false); // Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves. const heroActionsRef = useRef(null); const [heroCtaVisible, setHeroCtaVisible] = useState(true); // Hero map — the "View on map" link opens its fullscreen view. const heroMapRef = useRef(null); // "All ▾" jump menu listing every section. const [sectionsOpen, setSectionsOpen] = useState(false); // Back returns to wherever the user came from; deep-links (no in-app history) // fall back to search so the button never dead-ends or leaves the site. const handleBack = () => { if (typeof window !== 'undefined' && window.history.length > 1) { router.back(); } else { router.push('/search'); } }; const scrollToTop = () => { if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' }); }; useEffect(() => { const el = sectionLinksRef.current; if (!el) return; const update = () => { const overflow = el.scrollWidth - el.clientWidth; // No overflow → treat as "at end" so the fade is hidden. if (overflow <= 1) { setSectionNavAtEnd(true); return; } setSectionNavAtEnd(el.scrollLeft >= overflow - 2); }; update(); el.addEventListener('scroll', update, { passive: true }); window.addEventListener('resize', update); return () => { el.removeEventListener('scroll', update); window.removeEventListener('resize', update); }; }, []); // Track whether the hero's "Add to Compare" button is still on screen. useEffect(() => { const el = heroActionsRef.current; if (!el) return; const obs = new IntersectionObserver( ([entry]) => setHeroCtaVisible(entry.isIntersecting), { rootMargin: '-64px 0px 0px 0px' }, ); obs.observe(el); return () => obs.disconnect(); }, []); // Close the "All ▾" menu on Escape. useEffect(() => { if (!sectionsOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); }; window.addEventListener('keydown', onKey); 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 (phonics, // 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; // Primary-stage content shows for pure-primary AND all-through schools. const showPrimaryContent = isPrimary || isAllThrough; // National averages (fetched dynamically so they stay current) const [nationalAvg, setNationalAvg] = useState(null); useEffect(() => { fetch('/api/national-averages') .then(r => r.ok ? r.json() : null) .then(data => { if (data) setNationalAvg(data); }) .catch(() => {}); }, []); const primaryAvg = nationalAvg?.primary ?? {}; const secondaryAvg = nationalAvg?.secondary ?? {}; const handleComparisonToggle = () => { if (isInComparison) { removeSchool(schoolInfo.urn); track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' }); } else { addSchool(schoolInfo); track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' }); } }; // Page-view event with funnel attribution. Fires once per mount. useEffect(() => { track('school_viewed', { urn: schoolInfo.urn, phase: phase || 'unknown', local_authority: schoolInfo.local_authority || 'unknown', from: getNavigationSource(), }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [schoolInfo.urn]); const deprivationDesc = (decile: number) => { if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`; if (decile <= 7) return `This school is in an area with average levels of deprivation (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 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) || senDetail != null || hasGenderSplit; const hasSchoolLife = absenceData != null || census?.class_size_avg != null; const hasPhonics = phonics != null && phonics.year1_phonics_pct != 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 (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' }); 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 useEffect(() => { const ids = navItems.map(n => n.id); if (!ids.length) return; const observers: IntersectionObserver[] = []; const ratioMap: Record = {}; const pickActive = () => { const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0]; setActiveSection(top?.[1] > 0 ? top[0] : ''); }; ids.forEach(id => { const el = document.getElementById(id); if (!el) return; ratioMap[id] = 0; const obs = new IntersectionObserver( ([entry]) => { ratioMap[id] = entry.intersectionRatio; pickActive(); }, { threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' }, ); obs.observe(el); observers.push(obs); }); return () => observers.forEach(o => o.disconnect()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [navItems.map(n => n.id).join(',')]); // A report card is identified by the presence of report-card area // judgements, NOT by `framework` — the API sets `framework` to the raw // event grouping (e.g. "Schools - S5") even for report-card schools, so // the old `framework === 'ReportCard'` test never matched and report cards // were rendered as legacy ratings dated to a pre-Nov-2025 inspection. const isReportCard = !!( ofsted?.report_card && Object.keys(ofsted.report_card).length > 0 ); // A report card is dated by its own inspection (rc_inspection_date); the // legacy inspection_date belongs to an older inspection and must never // date a report card (report cards exist only from Nov 2025). const ofstedInspectedDate = isReportCard ? ofsted?.rc_inspection_date ?? null : ofsted?.inspection_date ?? null; // ── Ofsted: detect if all OEIF sub-grades match the overall ─────────── const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : []; const oeifAllSameGrade = !!ofsted && !isReportCard && oeifAreas.length >= 3 && oeifAreas.every((a) => a.value === ofsted.overall_effectiveness); // Label shown in the mobile "section" menu button — the section in view. const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? ''; return (
{/* Standalone back link, above the header — returns to wherever the user came from. Scrolls away with the page (the sticky bar keeps a "back to top" control in its place). */} {/* Header — the location map band blends down into the school title. */}
{hasLocation && ( )}

{schoolInfo.school_name}

{schoolInfo.local_authority && ( {schoolInfo.local_authority} )} {schoolInfo.school_type && ( {schoolInfo.school_type} )} {isAllThrough && ( All-through (primary & secondary) )} {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( {schoolInfo.gender}'s school )}
{isProposedToClose(schoolInfo) && (
⚠ Proposed to close — this school is proposed for closure, check with the local authority before applying.
)} {schoolInfo.address && (

{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} {hasLocation && ( <> {' · '} )}

)}
{schoolInfo.headteacher_name && ( Headteacher: {schoolInfo.headteacher_name} )} {schoolInfo.website && ( School website ↗ )} {(() => { const total = census?.total_pupils ?? latestResults?.total_pupils ?? null; if (total == null) return null; return ( Pupils: {total.toLocaleString()} {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} ); })()} {schoolInfo.trust_name && ( Part of {schoolInfo.trust_name} )}
{/* Sticky Section Navigation — docks under the global header */} {/* Ofsted Rating / Report Card */} {ofsted && (

{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'} {ofstedInspectedDate && ( Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} )} Ofsted reports ↗

{isReportCard ? ( /* ── New Report Card layout ── */ <>

From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.

{ofsted.rc_safeguarding_met != null && (
Safeguarding
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
)} {RC_CATEGORIES.map(({ key, label }) => { const value = ofsted[key] as number | null; return value != null ? (
{label}
{RC_LABELS[value]}
) : null; })}
) : ( /* ── Old OEIF layout ── */ <>
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'} {ofsted.previous_overall != null && ofsted.previous_overall !== ofsted.overall_effectiveness && ( Previously: {OFSTED_LABELS[ofsted.previous_overall]} )}

{ofsted.grade_source === 'ungraded_carried_forward' ? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.' : 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}

{oeifAllSameGrade ? (

Rated {OFSTED_LABELS[ofsted.overall_effectiveness!]} across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.

) : (
{oeifAreas.map(({ label, value }) => (
{label}
{OFSTED_LABELS[value]}
))}
)} )}
)} {/* Results Section (SATs for primary, GCSEs for secondary) */} {hasAnyResults && latestResults && (

{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})

{isSpecial ? (isSecondary ? 'GCSE results for Year 11 pupils.' : 'End-of-primary-school tests taken by Year 6 pupils.') : isAllThrough ? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.' : isSecondary ? 'GCSE results for Year 11 pupils. England averages shown for comparison.' : 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}

{/* Explains up front why the England comparison is dropped below, so a 0% headline never reads as a failing grade against a benchmark that doesn't fit. Type-aware copy (special vs PRU vs AP). */} {/* ── Primary / KS2 content ── */} {hasKS2Results && ( <> {isAllThrough && (

Primary — KS2 SATs (Year 6)

)}
{latestResults.rwm_expected_pct !== null && (
Reading, Writing & Maths combined
{formatPercentage(latestResults.rwm_expected_pct)} {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && ( )}
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%
)}
)} {latestResults.rwm_high_pct !== null && (
Exceeding expected level (Reading, Writing & Maths)
{formatPercentage(latestResults.rwm_high_pct)} {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && ( )}
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%
)}
)}
{!suppressKs2Comparison && latestResults.rwm_expected_pct != null && latestResults.reading_expected_pct != null && latestResults.writing_expected_pct != null && latestResults.maths_expected_pct != null && (
Why is combined lower? A pupil is only counted if they met the bar in{' '} all three subjects. Some passed reading but not writing; some passed writing but not maths.
Reading {latestResults.reading_expected_pct.toFixed(0)}% · Writing {latestResults.writing_expected_pct.toFixed(0)}% · Maths {latestResults.maths_expected_pct.toFixed(0)}% All three {latestResults.rwm_expected_pct.toFixed(0)}%
)} {/* All-zero placeholder rows (special / suppressed) would render as three empty bars against the national markers — misleading, so skip the chart. For a special school with some non-zero subjects, keep the bars but drop the national markers. */} {!ks2Placeholder && ( )} {/* Progress scores row */} {(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (

Progress Scores

{latestResults.reading_progress != null && (
Reading {formatProgress(latestResults.reading_progress)}
)} {latestResults.writing_progress != null && (
Writing {formatProgress(latestResults.writing_progress)}
)} {latestResults.maths_progress != null && (
Maths {formatProgress(latestResults.maths_progress)}
)}
)} {(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (

Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average.

)} )} {/* ── Secondary / KS4 content ── */} {hasKS4Results && ( <> {isAllThrough && (

Secondary — GCSEs (Year 11)

)}
{latestResults.attainment_8_score !== null && (
Attainment 8
{latestResults.attainment_8_score.toFixed(1)}
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
England avg: {secondaryAvg.attainment_8_score.toFixed(1)}
)}
)} {latestResults.progress_8_score !== null && (
Progress 8
{formatProgress(latestResults.progress_8_score)}
0 = national average
)} {latestResults.english_maths_standard_pass_pct !== null && (
English & Maths Grade 4+
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
)}
)} {latestResults.english_maths_strong_pass_pct !== null && (
English & Maths Grade 5+
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
)}
)}
{/* EBacc */} {(latestResults.ebacc_entry_pct !== null || latestResults.ebacc_standard_pass_pct !== null) && ( <>

English Baccalaureate (EBacc)

{latestResults.ebacc_entry_pct !== null && (
Pupils entered for EBacc {formatPercentage(latestResults.ebacc_entry_pct)}
)} {latestResults.ebacc_standard_pass_pct !== null && (
EBacc Grade 4+ {formatPercentage(latestResults.ebacc_standard_pass_pct)}
)} {latestResults.ebacc_strong_pass_pct !== null && (
EBacc Grade 5+ {formatPercentage(latestResults.ebacc_strong_pass_pct)}
)}
)} )}
)} {/* Admissions */} {admissions && (

Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}

{showAdmissionsTrend && (
)}
{/* All-through admissions data covers a single entry point (usually the Year 7 secondary intake), not reception — say so, or a parent could read these as the whole-school figures. */} {isAllThrough && admissions.school_phase && (

These figures are for {admissions.school_phase.toLowerCase()} entry {/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.

)}
{/* This-year Q&A */} {/* Multi-year trend */} {showAdmissionsTrend && ( )}
)} {/* Pupils & Inclusion */} {hasInclusionData && (

Pupils & Inclusion

{latestResults?.disadvantaged_pct != null && (
Eligible for pupil premium
{formatPercentage(latestResults.disadvantaged_pct)} {primaryAvg.disadvantaged_pct != null && ( )}
Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}
)} {latestResults?.eal_pct != null && (
English as an additional language
{formatPercentage(latestResults.eal_pct)} {primaryAvg.eal_pct != null && ( )}
{primaryAvg.eal_pct != null && (
England avg: {primaryAvg.eal_pct.toFixed(0)}%
)}
)} {latestResults?.sen_support_pct != null && (
Pupils receiving SEN support
{formatPercentage(latestResults.sen_support_pct)} {primaryAvg.sen_support_pct != null && ( )}
{primaryAvg.sen_support_pct != null && (
England avg: {primaryAvg.sen_support_pct.toFixed(0)}%
)}
)} {hasGenderSplit && (() => { const female = census!.female_pupils!; const male = census!.male_pupils!; const girlsPct = Math.round((female / (female + male)) * 100); const boysPct = 100 - girlsPct; return (
Boys and girls
{girlsPct}% girls · {boysPct}% boys
{female.toLocaleString()} girls, {male.toLocaleString()} boys
); })()}
{senDetail && ( <>

Types of additional needs supported

What proportion of pupils with additional needs have each type of support need.

{[ { label: 'Speech & Language', pct: senDetail.primary_need_speech_pct }, { label: 'Autism (ASD)', pct: senDetail.primary_need_autism_pct }, { label: 'Learning Difficulties', pct: senDetail.primary_need_mld_pct }, { label: 'Specific Learning (e.g. Dyslexia)', pct: senDetail.primary_need_spld_pct }, { label: 'Social, Emotional & Mental Health', pct: senDetail.primary_need_semh_pct }, { label: 'Physical / Sensory', pct: senDetail.primary_need_physical_pct }, ].filter(n => n.pct != null).map(({ label, pct }) => (
{label}
{pct}%
))}
)}
)} {/* Results Over Time (merged: chart + historical table) */} {yearlyData.length > 0 && (

Results Over Time

{isAllThrough ? ( // All-through: KS2 and KS4 trends are on different scales and have // different gap stories, so render them as two stacked charts // rather than crowding 8+ series onto one axis. <> {hasKS2Results && ( <>

Primary — KS2 SATs

)} {hasKS4Results && ( <>

Secondary — GCSEs

)} ) : (
)} {yearlyData.length > 1 && (
View raw year-by-year data
{isAllThrough ? ( <> ) : isSecondary ? ( <> ) : ( <> )} {yearlyData.map((result) => ( {isAllThrough ? ( <> ) : isSecondary ? ( <> ) : ( <> )} ))}
YearRWM (expected %) Exceeding (%) Attainment 8 Progress 8 English & Maths Grade 4+Attainment 8 Progress 8 English & Maths Grade 4+ English & Maths Grade 5+Reading, Writing & Maths (expected %) Exceeding expected (%) Reading Progress Writing Progress Maths Progress
{formatAcademicYear(result.year)}{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'} {result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'} {result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'} {result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'} {result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'} {result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'} {result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'} {result.english_maths_strong_pass_pct !== null ? formatPercentage(result.english_maths_strong_pass_pct) : '-'}{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'} {result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'} {result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'} {result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'} {result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}
)}
)} {/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */} {hasPhonics && showPrimaryContent && phonics && (

Year 1 Phonics ({formatAcademicYear(phonics.year)})

Phonics is a key early reading skill. Children are tested at the end of Year 1.

Passed the phonics check
{formatPercentage(phonics.year1_phonics_pct)}
Phonics is a key early reading skill tested at end of Year 1
{phonics.year2_phonics_pct != null && (
Year 2 pupils who retook and passed
{formatPercentage(phonics.year2_phonics_pct)}
)}
)} {/* School Life */} {hasSchoolLife && (

School Life

{census?.class_size_avg != null && (
Average class size
{census.class_size_avg.toFixed(1)}
Average number of pupils per class
)} {absenceData?.overall_absence_rate != null && (
Days missed (overall absence)
{formatPercentage(absenceData.overall_absence_rate)}
{primaryAvg.overall_absence_pct != null && (
England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%
)}
)} {absenceData?.persistent_absence_rate != null && (
Regularly missing school
{formatPercentage(absenceData.persistent_absence_rate)}
{primaryAvg.persistent_absence_pct != null && (
England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%
)}
)}
)} {/* Local Area Context */} {hasDeprivation && deprivation && (

Local Area Context

{Array.from({ length: 10 }, (_, i) => (
))}
Most deprived Least deprived

{deprivationDesc(deprivation.idaci_decile!)}

)} {/* Finances */} {hasFinance && finance && (

School Finances ({formatAcademicYear(finance.year)})

Per-pupil spending shows how much the school has to spend on each child's education.

Total spend per pupil per year
£{Math.round(finance.per_pupil_spend!).toLocaleString()}
How much the school has to spend on each pupil annually
{finance.teacher_cost_pct != null && (
Share of budget spent on teachers
{finance.teacher_cost_pct.toFixed(1)}%
)} {finance.staff_cost_pct != null && (
Share of budget spent on all staff
{finance.staff_cost_pct.toFixed(1)}%
)}
)}
); }