/** * 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 } 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; 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. National averages shown for comparison.

{p8Suspended && (
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
)} {/* Hero stat cards — top GCSE metrics */}
{latestResults.attainment_8_score != null && (
Attainment 8 score
{latestResults.attainment_8_score.toFixed(1)} {secondaryAvg.attainment_8_score != null && ( )}
{secondaryAvg.attainment_8_score != null && (
National 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)} {secondaryAvg.english_maths_strong_pass_pct != null && ( )}
{secondaryAvg.english_maths_strong_pass_pct != null && (
National 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)} {secondaryAvg.english_maths_standard_pass_pct != null && ( )}
{secondaryAvg.english_maths_standard_pass_pct != null && (
National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
)}
)}
{/* Attainment 8 visual bar (0–80 scale) */} {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)}%
)}
)}
); }