/** * SecondarySchoolDetailView Component * Dedicated detail view for secondary schools with scroll-to-section navigation. * All sections render at once; the sticky nav scrolls to each. */ 'use client'; import { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; import { useComparison } from '@/hooks/useComparison'; import { MetricTooltip } from './MetricTooltip'; import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap'; const PerformanceChart = dynamic( () => import('./PerformanceChart').then((m) => m.PerformanceChart), { ssr: false }, ); import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, SchoolAdmissions, SenDetail, Phonics, SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils'; import { DeltaChip } from './DeltaChip'; import { track, getNavigationSource } from '@/lib/analytics'; import styles from './SecondarySchoolDetailView.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, modStyles: Record): string { if (val == null) return ''; if (val > 0) return modStyles.progressPositive; if (val < 0) return modStyles.progressNegative; return ''; } function deprivationDesc(decile: number): string { 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).`; } interface SecondarySchoolDetailViewProps { schoolInfo: School; yearlyData: SchoolResult[]; absenceData: AbsenceData | null; ofsted: OfstedInspection | null; census: SchoolCensus | null; admissions: SchoolAdmissions | null; senDetail: SenDetail | null; phonics: Phonics | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; } export function SecondarySchoolDetailView({ schoolInfo, yearlyData, ofsted, census, admissions, senDetail, deprivation, finance, absenceData, }: SecondarySchoolDetailViewProps) { const router = useRouter(); // Hero map — the "View on map" link opens its fullscreen view. const heroMapRef = useRef(null); const { addSchool, removeSchool, isSelected } = useComparison(); const isInComparison = isSelected(schoolInfo.urn); const [activeSection, setActiveSection] = useState(''); const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; 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 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'; const denom = schoolInfo.religious_denomination ?? ''; if (denom && denom !== 'Does not apply') return 'Faith priority'; return null; })(); 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' }); } }; // Back returns wherever the user came from; deep-links 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(() => { track('school_viewed', { urn: schoolInfo.urn, phase: schoolInfo.phase || 'secondary', local_authority: schoolInfo.local_authority || 'unknown', from: getNavigationSource(), }); // 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' }); // 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 ); // Report cards are dated by their own inspection (rc_inspection_date), never // the legacy inspection_date (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); // National Attainment 8 baseline for the "Results Over Time" chart. const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null; return (
{/* Standalone back link, above the header — returns wherever the user came from. Scrolls away; the sticky bar keeps a "back to top" control. */} {/* ── Header — the location map band blends into the school title ── */}
{hasLocation && ( )}

{schoolInfo.school_name}

{schoolInfo.school_type && ( {schoolInfo.school_type} )} {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( {schoolInfo.gender}'s school )} {schoolInfo.age_range && ( {formatAgeRange(schoolInfo.age_range)} )} {hasSixthForm && ( Sixth form )} {admissionsTag && ( {admissionsTag} )}
{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 ↗ )} {(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && ( Pupils: {(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()} {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} )} {schoolInfo.trust_name && ( Part of {schoolInfo.trust_name} )}
{/* ── Sticky section navigation ─────────────────────── */} {/* ── Ofsted ─────────────────────────────────────── */} {ofsted && (

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

{isReportCard ? ( <>

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.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).map(({ key, label }) => { const value = ofsted[key] as number | null; return value != null ? (
{label}
{RC_LABELS[value]}
) : null; })}
) : ofsted.overall_effectiveness ? ( <>
{OFSTED_LABELS[ofsted.overall_effectiveness]} {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.'}

{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]}
))}
)} ) : ( <>

From September 2024, Ofsted no longer gives a single overall grade.

{[ { label: 'Quality of Education', value: ofsted.quality_of_education }, { label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes }, { label: 'Personal Development', value: ofsted.personal_development }, { label: 'Leadership & Management', value: ofsted.leadership_management }, ].filter(({ value }) => value != null).map(({ label, value }) => (
{label}
{OFSTED_LABELS[value!]}
))}
)}
)} {/* ── GCSE Results ───────────────────────────────── */} {hasResults && latestResults && (

GCSE Results ({formatAcademicYear(latestResults.year)})

GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}

{isSpecial && (
This is a special school. Its pupils have special educational needs and work towards individual targets. They sit the same GCSEs, but their headline attainment is far below the mainstream average by design, so a comparison with the England average isn’t a meaningful guide to the school.
)} {p8Suspended && (
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests (COVID), so DfE has no starting point to measure their progress from.
)} {/* Hero stat cards — top GCSE metrics */}
{latestResults.attainment_8_score != null && (
Attainment 8 score
{latestResults.attainment_8_score.toFixed(1)} {!suppressComparison && secondaryAvg.attainment_8_score != null && ( )}
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
England avg: {secondaryAvg.attainment_8_score.toFixed(1)}
)}
)} {latestResults.progress_8_score != null && (
Progress 8 score
{formatProgress(latestResults.progress_8_score)}
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
) : (
National baseline: 0.0
)}
)} {latestResults.english_maths_strong_pass_pct != null && (
English & Maths Grade 5+
{formatPercentage(latestResults.english_maths_strong_pass_pct)} {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && ( )}
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
)}
)} {latestResults.english_maths_standard_pass_pct != null && (
English & Maths Grade 4+
{formatPercentage(latestResults.english_maths_standard_pass_pct)} {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && ( )}
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
)}
)}
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly "school vs national", so it's dropped for special schools where that comparison isn't meaningful. */} {!suppressComparison && latestResults.attainment_8_score != null && (
Attainment 8 — school vs national
{secondaryAvg.attainment_8_score != null && (
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
)}
020406080
)} {/* Progress 8 number line with CI */} {latestResults.progress_8_score != null && !p8Suspended && (
Progress 8 — relative to national baseline (0)
{(() => { const p8 = latestResults.progress_8_score!; const lo = latestResults.progress_8_lower_ci ?? p8; const hi = latestResults.progress_8_upper_ci ?? p8; const range = 6; // −3 to +3 const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`; return (
{/* CI band */}
{/* Zero line */}
{/* Score dot */}
); })()}
−3−2−10+1+2+3
)} {/* Progress 8 component breakdown */} {(latestResults.progress_8_english != null || latestResults.progress_8_maths != null || latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && ( <>

Attainment 8 Components (Progress 8 contribution)

{[ { label: 'English', val: latestResults.progress_8_english }, { label: 'Maths', val: latestResults.progress_8_maths }, { label: 'EBacc subjects', val: latestResults.progress_8_ebacc }, { label: 'Open (other GCSEs)', val: latestResults.progress_8_open }, ].filter(r => r.val != null).map(({ label, val }) => (
{label} {formatProgress(val!)}
))}
)} {/* 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)}
)} {latestResults.ebacc_avg_score != null && (
EBacc average point score {latestResults.ebacc_avg_score.toFixed(2)}
)}
)}
)} {/* ── Admissions ─────────────────────────────────── */} {admissions && (

Admissions

{admissionsTag && (
{admissionsTag}{' '} {admissionsTag === 'Selective' ? '— Entry to this school is by selective examination (e.g. 11+).' : `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
)}
{admissions.places_offered != null && (
Year 7 places offered
{admissions.places_offered}
)} {admissions.total_applications != null && (
Total applications
{admissions.total_applications.toLocaleString()}
)} {admissions.first_preference_applications != null && (
1st preference applications
{admissions.first_preference_applications.toLocaleString()}
)} {admissions.first_preference_offer_pct != null && (
Families who got their first choice
{formatPercentage(admissions.first_preference_offer_pct)}
)}
{admissions.oversubscribed != null && (
{admissions.oversubscribed ? '⚠ Applications exceeded places last year' : '✓ Places were available last year'}
)}

Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.

{hasSixthForm && (
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
)}
)} {/* ── History table ──────────────────────────────── */} {yearlyData.length > 1 && (

Historical Results

{yearlyData.length > 0 && ( <>

Results Over Time

)}
View raw year-by-year data
{yearlyData.map((result) => ( ))}
Year Attainment 8 Progress 8 Eng & Maths 4+ EBacc entry %
{formatAcademicYear(result.year)} {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.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}
)} {/* ── Wellbeing ──────────────────────────────────── */} {hasWellbeing && (

Wellbeing & Context

{/* SEN */} {(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && ( <>

Special Educational Needs (SEN)

{latestResults?.sen_support_pct != null && (
SEN support
{formatPercentage(latestResults.sen_support_pct)}
Without an EHCP
)} {latestResults?.sen_ehcp_pct != null && (
Pupils with EHCP
{formatPercentage(latestResults.sen_ehcp_pct)}
Education, Health and Care Plan
)} {(() => { const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null; if (total == null) return null; const female = census?.female_pupils ?? null; const male = census?.male_pupils ?? null; const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null; const hasSplit = isMixed && female != null && male != null && female + male > 0; const sum = hasSplit ? female! + male! : 0; const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0; const boysPct = hasSplit ? 100 - girlsPct : 0; return (
Total pupils
{total.toLocaleString()}
{hasSplit && ( <>
{girlsPct}% girls · {boysPct}% boys
)} {schoolInfo.capacity != null && !hasSplit && (
Capacity: {schoolInfo.capacity}
)}
); })()}
)} {/* Deprivation */} {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)}%
)} {finance.premises_cost_pct != null && (
Share of budget spent on premises
{finance.premises_cost_pct.toFixed(1)}%
)}
)}
); }