2026-02-02 20:34:35 +00:00
/**
* SchoolDetailView Component
* Displays comprehensive school information with performance charts
*/
'use client' ;
2026-05-18 15:35:17 +01:00
import { useEffect , useRef , useState } from 'react' ;
2026-03-23 21:31:28 +00:00
import { useRouter } from 'next/navigation' ;
2026-06-02 13:46:45 +01:00
import dynamic from 'next/dynamic' ;
2026-02-02 20:34:35 +00:00
import { useComparison } from '@/hooks/useComparison' ;
2026-07-01 18:12:12 +01:00
import { SchoolHeroMap , type SchoolHeroMapHandle } from './SchoolHeroMap' ;
2026-03-28 14:59:40 +00:00
import { MetricTooltip } from './MetricTooltip' ;
2026-03-24 11:44:04 +00:00
import type {
School , SchoolResult , AbsenceData ,
2026-07-06 09:01:26 +01:00
OfstedInspection , SchoolCensus ,
2026-03-24 11:44:04 +00:00
SchoolAdmissions , SenDetail , Phonics ,
2026-03-28 14:59:40 +00:00
SchoolDeprivation , SchoolFinance , NationalAverages ,
2026-03-24 11:44:04 +00:00
} from '@/lib/types' ;
2026-04-08 10:32:33 +01:00
import {
2026-07-20 07:15:33 +01:00
formatPercentage , formatProgress , formatAcademicYear , isProposedToClose , ofstedLegacyAreas ,
2026-04-08 10:32:33 +01:00
} from '@/lib/utils' ;
import { DeltaChip } from './DeltaChip' ;
2026-07-20 12:03:23 +01:00
import { summariseAdmissions } from '@/lib/compareLogic' ;
2026-06-02 13:46:45 +01:00
const PerformanceChart = dynamic (
() => import ( './PerformanceChart' ). then (( m ) => m . PerformanceChart ),
{ ssr : false },
);
const SatsChart = dynamic (() => import ( './SatsChart' ), { ssr : false });
2026-06-19 18:59:57 +01:00
const AdmissionsTrendChart = dynamic (() => import ( './AdmissionsTrendChart' ), { ssr : false });
2026-05-19 22:04:22 +01:00
import { track , getNavigationSource } from '@/lib/analytics' ;
2026-02-02 20:34:35 +00:00
import styles from './SchoolDetailView.module.css' ;
2026-03-24 11:44:04 +00:00
const OFSTED_LABELS : Record < number , string > = {
1 : 'Outstanding' , 2 : 'Good' , 3 : 'Requires Improvement' , 4 : 'Inadequate' ,
};
2026-03-25 13:03:04 +00:00
const RC_LABELS : Record < number , string > = {
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' },
];
2026-03-25 10:06:36 +00:00
function progressClass ( val : number | null | undefined ) : string {
if ( val == null ) return '' ;
2026-03-25 10:34:19 +00:00
if ( val > 0 ) return styles . progressPositive ;
if ( val < 0 ) return styles . progressNegative ;
2026-03-25 10:06:36 +00:00
return '' ;
}
2026-02-02 20:34:35 +00:00
interface SchoolDetailViewProps {
schoolInfo : School ;
yearlyData : SchoolResult [];
absenceData : AbsenceData | null ;
2026-03-24 11:44:04 +00:00
ofsted : OfstedInspection | null ;
census : SchoolCensus | null ;
admissions : SchoolAdmissions | null ;
2026-06-19 18:41:03 +01:00
admissionsHistory : SchoolAdmissions [];
2026-03-24 11:44:04 +00:00
senDetail : SenDetail | null ;
phonics : Phonics | null ;
deprivation : SchoolDeprivation | null ;
finance : SchoolFinance | null ;
2026-02-02 20:34:35 +00:00
}
2026-03-24 11:44:04 +00:00
export function SchoolDetailView ({
schoolInfo , yearlyData , absenceData ,
2026-07-06 09:01:26 +01:00
ofsted , census , admissions , admissionsHistory , senDetail , phonics , deprivation , finance ,
2026-03-24 11:44:04 +00:00
} : SchoolDetailViewProps ) {
2026-03-23 21:31:28 +00:00
const router = useRouter ();
2026-02-02 20:34:35 +00:00
const { addSchool , removeSchool , isSelected } = useComparison ();
const isInComparison = isSelected ( schoolInfo . urn );
2026-04-08 15:42:20 +01:00
const [ activeSection , setActiveSection ] = useState < string >( '' );
2026-06-19 18:41:03 +01:00
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 ;
2026-07-20 12:03:23 +01:00
// 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 );
2026-06-30 22:39:48 +01:00
// Only the section links scroll horizontally; Back and "All" stay pinned.
const sectionLinksRef = useRef < HTMLDivElement | null >( null );
2026-05-18 15:35:17 +01:00
const [ sectionNavAtEnd , setSectionNavAtEnd ] = useState ( false );
2026-06-30 22:39:48 +01:00
// Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves.
const heroActionsRef = useRef < HTMLDivElement | null >( null );
const [ heroCtaVisible , setHeroCtaVisible ] = useState ( true );
2026-07-01 18:12:12 +01:00
// Hero map — the "View on map" link opens its fullscreen view.
const heroMapRef = useRef < SchoolHeroMapHandle >( null );
2026-06-30 22:39:48 +01:00
// "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' );
}
};
2026-05-18 15:35:17 +01:00
2026-07-01 14:48:17 +01:00
const scrollToTop = () => {
if ( typeof window !== 'undefined' ) window . scrollTo ({ top : 0 , behavior : 'smooth' });
};
2026-05-18 15:35:17 +01:00
useEffect (() => {
2026-06-30 22:39:48 +01:00
const el = sectionLinksRef . current ;
2026-05-18 15:35:17 +01:00
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 );
};
}, []);
2026-04-08 15:42:20 +01:00
2026-06-30 22:39:48 +01:00
// 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 ]);
2026-02-02 20:34:35 +00:00
const latestResults = yearlyData . length > 0 ? yearlyData [ yearlyData . length - 1 ] : null ;
2026-07-20 12:53:07 +01:00
// 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.
2026-03-28 14:59:40 +00:00
const phase = schoolInfo . phase ?? '' ;
2026-07-20 12:53:07 +01:00
const isAllThrough = phase . toLowerCase () === 'all-through' ;
const isSecondary = phase . toLowerCase (). includes ( 'secondary' ) || isAllThrough ;
2026-03-28 14:59:40 +00:00
const isPrimary = ! isSecondary ;
2026-07-20 12:53:07 +01:00
// Primary-stage content shows for pure-primary AND all-through schools.
const showPrimaryContent = isPrimary || isAllThrough ;
2026-03-28 14:59:40 +00:00
// National averages (fetched dynamically so they stay current)
const [ nationalAvg , setNationalAvg ] = useState < NationalAverages | null >( 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 ?? {};
2026-02-02 20:34:35 +00:00
const handleComparisonToggle = () => {
if ( isInComparison ) {
removeSchool ( schoolInfo . urn );
2026-05-19 22:04:22 +01:00
track ( 'compare_school_removed' , { urn : schoolInfo.urn , from : 'detail' });
2026-02-02 20:34:35 +00:00
} else {
addSchool ( schoolInfo );
2026-05-19 22:04:22 +01:00
track ( 'compare_school_added' , { urn : schoolInfo.urn , from : 'detail' });
2026-02-02 20:34:35 +00:00
}
};
2026-05-19 22:04:22 +01:00
// 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 ]);
2026-03-25 10:06:36 +00:00
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).` ;
};
2026-04-17 22:52:59 +01:00
// 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 ;
2026-03-25 10:34:19 +00:00
// 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 )
2026-04-17 22:52:59 +01:00
|| senDetail != null
|| hasGenderSplit ;
2026-03-25 10:34:19 +00:00
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 ;
2026-03-28 14:59:40 +00:00
// 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 ;
2026-07-01 09:51:46 +01:00
// 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.
2026-03-25 10:34:19 +00:00
const navItems : { id : string ; label : string }[] = [];
if ( ofsted ) navItems . push ({ id : 'ofsted' , label : 'Ofsted' });
2026-07-20 12:53:07 +01:00
if ( hasAnyResults ) navItems . push ({ id : 'results' , label : isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' });
2026-03-25 10:34:19 +00:00
if ( admissions ) navItems . push ({ id : 'admissions' , label : 'Admissions' });
if ( hasInclusionData ) navItems . push ({ id : 'inclusion' , label : 'Pupils' });
2026-07-01 09:51:46 +01:00
if ( yearlyData . length > 0 ) navItems . push ({ id : 'history' , label : 'History' });
2026-07-20 12:53:07 +01:00
if ( hasPhonics && showPrimaryContent ) navItems . push ({ id : 'phonics' , label : 'Phonics' });
2026-07-01 09:51:46 +01:00
if ( hasSchoolLife ) navItems . push ({ id : 'school-life' , label : 'School Life' });
2026-03-25 10:34:19 +00:00
if ( hasDeprivation ) navItems . push ({ id : 'local-area' , label : 'Local Area' });
if ( hasFinance ) navItems . push ({ id : 'finances' , label : 'Finances' });
2026-04-08 15:59:01 +01:00
// Track active section as user scrolls
useEffect (() => {
const ids = navItems . map ( n => n . id );
if ( ! ids . length ) return ;
const observers : IntersectionObserver [] = [];
const ratioMap : Record < string , number > = {};
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 ( ',' )]);
2026-07-18 19:09:16 +01:00
// 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 ;
2026-04-08 15:13:13 +01:00
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
2026-07-20 07:15:33 +01:00
const oeifAreas = ofsted ? ofstedLegacyAreas ( ofsted ) : [];
const oeifAllSameGrade =
!! ofsted &&
! isReportCard &&
oeifAreas . length >= 3 &&
oeifAreas . every (( a ) => a . value === ofsted . overall_effectiveness );
2026-04-08 15:13:13 +01:00
2026-06-30 23:27:03 +01:00
// Label shown in the mobile "section" menu button — the section in view.
const activeNavLabel = ( navItems . find (( n ) => n . id === activeSection ) ?? navItems [ 0 ]) ? . label ?? '' ;
2026-02-02 20:34:35 +00:00
return (
< div className = { styles . container }>
2026-07-01 14:48:17 +01:00
{ /* 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). */ }
< button type = "button" onClick = { handleBack } className = { styles . topBack }>
< span aria-hidden = "true" > ← </ span > Back
</ button >
2026-07-01 18:12:12 +01:00
{ /* Header — the location map band blends down into the school title. */ }
< header className = { ` ${ styles . header }${ hasLocation ? ` ${ styles . headerHasMap } ` : '' } ` }>
{ hasLocation && (
< SchoolHeroMap ref = { heroMapRef } lat = { schoolInfo . latitude ! } lng = { schoolInfo . longitude ! } />
)}
2026-02-02 20:34:35 +00:00
< div className = { styles . headerContent }>
< div className = { styles . titleSection }>
< h1 className = { styles . schoolName }>{ schoolInfo . school_name }</ h1 >
< div className = { styles . meta }>
{ schoolInfo . local_authority && (
2026-03-25 10:06:36 +00:00
< span className = { styles . metaItem }>{ schoolInfo . local_authority }</ span >
2026-02-02 20:34:35 +00:00
)}
{ schoolInfo . school_type && (
2026-03-25 10:06:36 +00:00
< span className = { styles . metaItem }>{ schoolInfo . school_type }</ span >
)}
2026-07-20 12:53:07 +01:00
{ isAllThrough && (
< span className = { styles . metaItem }> All - through ( primary & amp ; secondary )</ span >
)}
2026-03-25 10:06:36 +00:00
{ schoolInfo . gender && schoolInfo . gender !== 'Mixed' && (
< span className = { styles . metaItem }>{ schoolInfo . gender } & apos ; s school </ span >
2026-02-02 20:34:35 +00:00
)}
</ div >
2026-07-08 22:05:55 +01:00
{ isProposedToClose ( schoolInfo ) && (
< div className = { styles . closingStrip } role = "note" >
< strong > ⚠ Proposed to close </ strong > — this school is proposed for closure ,
2026-07-08 22:51:32 +01:00
check with the local authority before applying .
2026-07-08 22:05:55 +01:00
</ div >
)}
2026-02-02 20:34:35 +00:00
{ schoolInfo . address && (
< p className = { styles . address }>
2026-03-25 10:06:36 +00:00
{ schoolInfo . address }{ schoolInfo . postcode && `, ${ schoolInfo . postcode } ` }
2026-07-01 18:12:12 +01:00
{ hasLocation && (
<>
{ ' · ' }
< button
type = "button"
className = { styles . mapLink }
onClick = {() => { heroMapRef . current ? . open (); track ( 'section_nav_used' , { section : 'location' , via : 'hero_link' }); }}
>
View on map ↗
</ button >
</>
)}
2026-02-02 20:34:35 +00:00
</ p >
)}
2026-03-25 10:06:36 +00:00
< div className = { styles . headerDetails }>
{ schoolInfo . headteacher_name && (
< span className = { styles . headerDetail }>
< strong > Headteacher : </ strong > { schoolInfo . headteacher_name }
</ span >
)}
{ schoolInfo . website && (
< span className = { styles . headerDetail }>
2026-05-19 22:04:22 +01:00
< a
href = { /^ https ?: \ / \ //i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
target = "_blank"
rel = "noopener noreferrer"
data - umami - event = "external_link_clicked"
data - umami - event - target = "school_website"
>
2026-03-25 10:06:36 +00:00
School website ↗
</ a >
</ span >
)}
2026-04-17 22:36:33 +01:00
{ (() = > {
2026-04-17 22:52:59 +01:00
const total = census ? . total_pupils ?? latestResults ? . total_pupils ?? null ;
if ( total == null ) return null ;
return (
< span className = { styles . headerDetail }>
< strong > Pupils : </ strong > { total . toLocaleString ()}
{ schoolInfo . capacity != null && ` (capacity: ${ schoolInfo . capacity } )` }
</ span >
);
2026-04-17 22:36:33 +01:00
})()}
2026-03-25 10:06:36 +00:00
{ schoolInfo . trust_name && (
< span className = { styles . headerDetail }>
Part of < strong >{ schoolInfo . trust_name }</ strong >
</ span >
)}
</ div >
2026-02-02 20:34:35 +00:00
</ div >
2026-06-30 22:39:48 +01:00
< div className = { styles . actions } ref = { heroActionsRef }>
2026-02-02 20:34:35 +00:00
< button
onClick = { handleComparisonToggle }
className = { isInComparison ? styles.btnRemove : styles.btnAdd }
2026-07-02 16:17:12 +01:00
aria-label = { isInComparison ? 'In comparison' : 'Add to compare' }
2026-02-02 20:34:35 +00:00
>
2026-07-02 16:17:12 +01:00
{ /* On phones the map hero shows only the glyph (nav-bar style). */ }
< span className = { styles . btnCompareLabel }>
{ isInComparison ? '✓ In Comparison' : '+ Add to Compare' }
</ span >
< span className = { styles . btnCompareGlyph } aria-hidden = "true" >
{ isInComparison ? '✓' : '+' }
</ span >
2026-02-02 20:34:35 +00:00
</ button >
</ div >
</ div >
</ header >
2026-06-30 22:39:48 +01:00
{ /* Sticky Section Navigation — docks under the global header */ }
< nav className = { styles . sectionNav } aria-label = "Page sections" >
2026-07-01 14:48:17 +01:00
< button onClick = { scrollToTop } className = { styles . sectionNavBack } aria-label = "Back to top" >
< span aria-hidden = "true" > ↑ </ span >
< span className = { styles . sectionNavBackLabel }> Top </ span >
2026-06-30 23:27:03 +01:00
</ button >
2026-06-30 22:39:48 +01:00
2026-06-30 23:27:03 +01:00
{ /* Desktop: scrolling section links */ }
2026-06-30 22:39:48 +01:00
< div
ref = { sectionLinksRef }
className = { ` ${ styles . sectionNavLinks }${ sectionNavAtEnd ? ` ${ styles . atEnd } ` : '' } ` }
>
2026-03-25 11:13:55 +00:00
{ navItems . map (({ id , label }) => (
2026-04-08 15:42:20 +01:00
< a
key = { id }
href = { `# ${ id } ` }
className = { ` ${ styles . sectionNavLink }${ activeSection === id ? ` ${ styles . sectionNavLinkActive } ` : '' } ` }
2026-05-19 22:04:22 +01:00
onClick = {() => track ( 'section_nav_used' , { section : id })}
2026-04-08 15:42:20 +01:00
>
{ label }
</ a >
2026-03-25 11:13:55 +00:00
))}
</ div >
2026-06-30 22:39:48 +01:00
2026-06-30 23:27:03 +01:00
{ /* Mobile: a single "section" menu button that opens the jump sheet */ }
{ navItems . length > 0 && (
2026-06-30 22:39:48 +01:00
< button
2026-06-30 23:27:03 +01:00
type = "button"
className = { styles . sectionNavMenu }
aria-haspopup = "menu"
aria-expanded = { sectionsOpen }
onClick = {() => setSectionsOpen (( o ) => ! o )}
2026-06-30 22:39:48 +01:00
>
2026-06-30 23:27:03 +01:00
< span className = { styles . sectionNavMenuCur }>
< span className = { styles . sectionNavMenuEyebrow }> Section </ span >
< span className = { styles . sectionNavMenuNow }>{ activeNavLabel }</ span >
</ span >
< span className = { styles . sectionNavMenuChev } aria-hidden = "true" > ▾ </ span >
2026-06-30 22:39:48 +01:00
</ button >
)}
2026-06-30 23:27:03 +01:00
{ /* The hero's Compare CTA, carried in once it scrolls out of view.
Desktop shows a labelled pill; mobile a compact icon. */ }
{ ! heroCtaVisible && (
<>
2026-06-30 22:39:48 +01:00
< button
2026-06-30 23:27:03 +01:00
onClick = { handleComparisonToggle }
className = { ` ${ styles . sectionNavCompare }${ isInComparison ? ` ${ styles . sectionNavCompareIn } ` : '' } ` }
2026-06-30 22:39:48 +01:00
>
2026-06-30 23:27:03 +01:00
{ isInComparison ? '✓ Comparing' : '+ Compare' }
2026-06-30 22:39:48 +01:00
</ button >
2026-06-30 23:27:03 +01:00
< button
onClick = { handleComparisonToggle }
className = { ` ${ styles . sectionNavCompareIcon }${ isInComparison ? ` ${ styles . sectionNavCompareIconIn } ` : '' } ` }
aria-label = { isInComparison ? 'In comparison' : 'Add to compare' }
title = { isInComparison ? 'In comparison' : 'Add to compare' }
>
{ isInComparison ? (
< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.4" strokeLinecap = "round" strokeLinejoin = "round" aria-hidden = "true" >
< path d = "m5 12 5 5 9-11" />
</ svg >
) : (
<>
< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" aria-hidden = "true" >
< path d = "M4 7h13l-3-3" />
< path d = "M20 17H7l3 3" />
</ svg >
< span className = { styles . sectionNavCompareBadge } aria-hidden = "true" > + </ span >
</>
)}
</ button >
</>
)}
{ /* Desktop: "All ▾" trigger (same sheet as the mobile section menu) */ }
{ navItems . length > 0 && (
< button
type = "button"
className = { styles . sectionNavAll }
aria-haspopup = "menu"
aria-expanded = { sectionsOpen }
onClick = {() => setSectionsOpen (( o ) => ! o )}
>
All < span aria-hidden = "true" > ▾ </ span >
</ button >
)}
{ /* Shared jump-to-section sheet (dropdown on desktop, bottom sheet on mobile) */ }
{ sectionsOpen && (
<>
< div className = { styles . sectionsBackdrop } onClick = {() => setSectionsOpen ( false )} />
< div className = { styles . sectionsPanel } role = "menu" aria-label = "Jump to section" >
< div className = { styles . sectionsPanelHead }> Jump to section </ div >
{ navItems . map (({ id , label }) => (
< a
key = { id }
href = { `# ${ id } ` }
role = "menuitem"
className = { ` ${ styles . sectionsItem }${ activeSection === id ? ` ${ styles . sectionsItemActive } ` : '' } ` }
onClick = {() => {
setSectionsOpen ( false );
track ( 'section_nav_used' , { section : id , via : 'all_menu' });
}}
>
< span >{ label }</ span >
{ activeSection === id && < span className = { styles . sectionsTick } aria-hidden = "true" > ✓ </ span >}
</ a >
))}
</ div >
</>
2026-06-30 22:39:48 +01:00
)}
2026-03-25 11:13:55 +00:00
</ nav >
2026-03-25 10:06:36 +00:00
2026-03-25 13:03:04 +00:00
{ /* Ofsted Rating / Report Card */ }
2026-03-25 10:06:36 +00:00
{ ofsted && (
2026-03-25 10:34:19 +00:00
< section id = "ofsted" className = { styles . card }>
2026-02-02 20:34:35 +00:00
< h2 className = { styles . sectionTitle }>
2026-07-18 19:09:16 +01:00
{ isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating' }
{ ofstedInspectedDate && (
2026-03-25 10:06:36 +00:00
< span className = { styles . ofstedDate }>
2026-07-18 19:09:16 +01:00
Inspected { new Date ( ofstedInspectedDate ). toLocaleDateString ( 'en-GB' , { day : 'numeric' , month : 'long' , year : 'numeric' })}
2026-03-25 10:06:36 +00:00
</ span >
)}
< a
2026-07-01 22:26:48 +01:00
href = { `https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/ ${ schoolInfo . urn } ` }
2026-03-25 10:06:36 +00:00
target = "_blank"
rel = "noopener noreferrer"
className = { styles . ofstedReportLink }
2026-05-19 22:04:22 +01:00
data-umami-event = "external_link_clicked"
data-umami-event-target = "ofsted"
2026-03-25 10:06:36 +00:00
>
2026-07-01 22:26:48 +01:00
Ofsted reports ↗
2026-03-25 10:06:36 +00:00
</ a >
2026-02-02 20:34:35 +00:00
</ h2 >
2026-03-25 13:03:04 +00:00
2026-07-18 19:09:16 +01:00
{ isReportCard ? (
2026-03-25 13:03:04 +00:00
/* ── New Report Card layout ── */
<>
< p className = { styles . ofstedDisclaimer }>
From November 2025 , Ofsted replaced single overall grades with Report Cards rating schools across several areas .
</ p >
< div className = { styles . metricsGrid }>
2026-03-25 15:17:45 +00:00
{ ofsted . rc_safeguarding_met != null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }> Safeguarding </ div >
< div className = { ` ${ styles . metricValue } ${ ofsted . rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet } ` }>
{ ofsted . rc_safeguarding_met ? 'Met' : 'Not met' }
</ div >
</ div >
)}
2026-03-25 13:03:04 +00:00
{ RC_CATEGORIES . map (({ key , label }) => {
const value = ofsted [ key ] as number | null ;
return value != null ? (
< div key = { key } className = { styles . metricCard }>
< div className = { styles . metricLabel }>{ label }</ div >
< div className = { ` ${ styles . metricValue } ${ styles [ `rcGrade ${ value } ` ] } ` }>
{ RC_LABELS [ value ]}
</ div >
</ div >
) : null ;
})}
2026-03-25 10:06:36 +00:00
</ div >
2026-03-25 13:03:04 +00:00
</>
) : (
/* ── Old OEIF layout ── */
<>
< div className = { styles . ofstedHeader }>
< span className = { ` ${ styles . ofstedGrade } ${ styles [ `ofstedGrade ${ ofsted . overall_effectiveness } ` ] } ` }>
{ ofsted . overall_effectiveness ? OFSTED_LABELS [ ofsted . overall_effectiveness ] : 'Not rated' }
</ span >
{ ofsted . previous_overall != null &&
ofsted . previous_overall !== ofsted . overall_effectiveness && (
< span className = { styles . ofstedPrevious }>
Previously : { OFSTED_LABELS [ ofsted . previous_overall ]}
</ span >
)}
</ div >
< p className = { styles . ofstedDisclaimer }>
2026-07-20 07:15:33 +01:00
{ 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.' }
2026-03-25 13:03:04 +00:00
</ p >
2026-04-08 15:13:13 +01:00
{ oeifAllSameGrade ? (
< p className = { styles . ofstedAllSame }>
Rated < strong >{ OFSTED_LABELS [ ofsted . overall_effectiveness ! ]}</ strong > across all inspected areas — Quality of Teaching , Behaviour , Pupils & apos ; Development and Leadership .
</ p >
) : (
< div className = { styles . metricsGrid }>
2026-07-20 07:15:33 +01:00
{ oeifAreas . map (({ label , value }) => (
2026-04-08 15:13:13 +01:00
< div key = { label } className = { styles . metricCard }>
< div className = { styles . metricLabel }>{ label }</ div >
< div className = { ` ${ styles . metricValue } ${ styles [ `ofstedGrade ${ value } ` ] } ` }>
{ OFSTED_LABELS [ value ]}
</ div >
2026-03-25 13:03:04 +00:00
</ div >
2026-04-08 15:13:13 +01:00
))}
</ div >
)}
2026-03-25 13:03:04 +00:00
</>
)}
2026-03-25 10:06:36 +00:00
</ section >
)}
2026-03-28 14:59:40 +00:00
{ /* Results Section (SATs for primary, GCSEs for secondary) */ }
{ hasAnyResults && latestResults && (
< section id = "results" className = { styles . card }>
< h2 className = { styles . sectionTitle }>
2026-07-20 12:53:07 +01:00
{ isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results' } ({ formatAcademicYear ( latestResults . year )})
2026-03-28 14:59:40 +00:00
</ h2 >
2026-03-25 10:06:36 +00:00
< p className = { styles . sectionSubtitle }>
2026-07-20 12:53:07 +01:00
{ 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.' }
2026-03-25 10:06:36 +00:00
</ p >
2026-03-25 10:34:19 +00:00
2026-03-28 14:59:40 +00:00
{ /* ── Primary / KS2 content ── */ }
{ hasKS2Results && (
<>
2026-07-20 12:53:07 +01:00
{ isAllThrough && (
< h3 className = { styles . subSectionTitle }> Primary — KS2 SATs ( Year 6 )</ h3 >
)}
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatGrid }>
2026-03-28 14:59:40 +00:00
{ latestResults . rwm_expected_pct !== null && (
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatCard }>
< div className = { styles . heroStatLabel }>
2026-03-28 14:59:40 +00:00
Reading , Writing & amp ; Maths combined
< MetricTooltip metricKey = "rwm_expected_pct" />
</ div >
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatValue }>
2026-04-08 10:32:33 +01:00
{ formatPercentage ( latestResults . rwm_expected_pct )}
{ primaryAvg . rwm_expected_pct != null && (
< DeltaChip
value = { latestResults . rwm_expected_pct }
baseline = { primaryAvg . rwm_expected_pct }
unit = "pts"
size = "sm"
/>
)}
</ div >
2026-03-28 14:59:40 +00:00
{ primaryAvg . rwm_expected_pct != null && (
2026-07-20 07:19:22 +01:00
< div className = { styles . heroStatHint }> England avg : { primaryAvg . rwm_expected_pct . toFixed ( 0 )} % </ div >
2026-03-28 14:59:40 +00:00
)}
</ div >
)}
{ latestResults . rwm_high_pct !== null && (
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatCard }>
< div className = { styles . heroStatLabel }>
2026-03-28 14:59:40 +00:00
Exceeding expected level ( Reading , Writing & amp ; Maths )
< MetricTooltip metricKey = "rwm_high_pct" />
</ div >
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatValue }>
2026-04-08 10:32:33 +01:00
{ formatPercentage ( latestResults . rwm_high_pct )}
{ primaryAvg . rwm_high_pct != null && (
< DeltaChip
value = { latestResults . rwm_high_pct }
baseline = { primaryAvg . rwm_high_pct }
unit = "pts"
size = "sm"
/>
)}
</ div >
2026-03-28 14:59:40 +00:00
{ primaryAvg . rwm_high_pct != null && (
2026-07-20 07:19:22 +01:00
< div className = { styles . heroStatHint }> England avg : { primaryAvg . rwm_high_pct . toFixed ( 0 )} % </ div >
2026-03-28 14:59:40 +00:00
)}
</ div >
)}
2026-02-02 20:34:35 +00:00
</ div >
2026-03-23 21:31:28 +00:00
2026-04-14 13:30:11 +01:00
{ latestResults . rwm_expected_pct != null &&
latestResults . reading_expected_pct != null &&
latestResults . writing_expected_pct != null &&
latestResults . maths_expected_pct != null && (
< div className = { styles . rwmBridge }>
< span className = { styles . rwmBridgeIcon } aria-hidden = "true" > ? </ span >
< div className = { styles . rwmBridgeBody }>
< div className = { styles . rwmBridgeText }>
Why is combined lower ? A pupil is only counted if they met the bar in { ' ' }
< strong > all three </ strong > subjects . Some passed reading but not writing ; some passed writing but not maths .
</ div >
< div className = { styles . rwmBridgeMath }>
< span > Reading < strong >{ latestResults . reading_expected_pct . toFixed ( 0 )} % </ strong ></ span >
< span className = { styles . rwmBridgeMathSep }> · </ span >
< span > Writing < strong >{ latestResults . writing_expected_pct . toFixed ( 0 )} % </ strong ></ span >
< span className = { styles . rwmBridgeMathSep }> · </ span >
< span > Maths < strong >{ latestResults . maths_expected_pct . toFixed ( 0 )} % </ strong ></ span >
< span className = { styles . rwmBridgeMathSep }> → </ span >
< span > All three < strong >{ latestResults . rwm_expected_pct . toFixed ( 0 )} % </ strong ></ span >
</ div >
</ div >
</ div >
)}
2026-04-13 21:22:24 +01:00
< SatsChart
subjects = {[
{
name : 'Reading' ,
expectedPct : latestResults.reading_expected_pct ,
exceedingPct : latestResults.reading_high_pct ,
nationalExpectedPct : primaryAvg.reading_expected_pct ,
},
{
name : 'Writing' ,
expectedPct : latestResults.writing_expected_pct ,
exceedingPct : latestResults.writing_high_pct ,
nationalExpectedPct : primaryAvg.writing_expected_pct ,
},
{
name : 'Maths' ,
expectedPct : latestResults.maths_expected_pct ,
exceedingPct : latestResults.maths_high_pct ,
nationalExpectedPct : primaryAvg.maths_expected_pct ,
},
]}
/>
{ /* Progress scores row */ }
{( latestResults . reading_progress != null || latestResults . writing_progress != null || latestResults . maths_progress != null ) && (
< div className = { styles . progressScoresRow }>
< h3 className = { styles . subSectionTitle }> Progress Scores </ h3 >
< div className = { styles . progressScoresGrid }>
{ latestResults . reading_progress != null && (
< div className = { styles . progressScoreItem }>
< span className = { styles . progressScoreLabel }> Reading </ span >
< span className = { ` ${ styles . progressScoreValue } ${ progressClass ( latestResults . reading_progress ) } ` }>
2026-03-28 14:59:40 +00:00
{ formatProgress ( latestResults . reading_progress )}
</ span >
</ div >
)}
2026-04-13 21:22:24 +01:00
{ latestResults . writing_progress != null && (
< div className = { styles . progressScoreItem }>
< span className = { styles . progressScoreLabel }> Writing </ span >
< span className = { ` ${ styles . progressScoreValue } ${ progressClass ( latestResults . writing_progress ) } ` }>
2026-03-28 14:59:40 +00:00
{ formatProgress ( latestResults . writing_progress )}
</ span >
</ div >
)}
2026-04-13 21:22:24 +01:00
{ latestResults . maths_progress != null && (
< div className = { styles . progressScoreItem }>
< span className = { styles . progressScoreLabel }> Maths </ span >
< span className = { ` ${ styles . progressScoreValue } ${ progressClass ( latestResults . maths_progress ) } ` }>
2026-03-28 14:59:40 +00:00
{ formatProgress ( latestResults . maths_progress )}
</ span >
</ div >
)}
</ div >
</ div >
2026-04-13 21:22:24 +01:00
)}
2026-03-28 14:59:40 +00:00
{( latestResults . reading_progress !== null || latestResults . writing_progress !== null || latestResults . maths_progress !== null ) && (
< p className = { styles . progressNote }>
Progress scores measure how much pupils improved compared to similar schools nationally . Above 0 = better than average , below 0 = below average .
</ p >
)}
</>
)}
{ /* ── Secondary / KS4 content ── */ }
{ hasKS4Results && (
<>
2026-07-20 12:53:07 +01:00
{ isAllThrough && (
< h3 className = { styles . subSectionTitle } style = {{ marginTop : '1.5rem' }}> Secondary — GCSEs ( Year 11 )</ h3 >
)}
2026-03-28 14:59:40 +00:00
< div className = { styles . metricsGrid }>
{ latestResults . attainment_8_score !== null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }>
Attainment 8
< MetricTooltip metricKey = "attainment_8_score" />
</ div >
< div className = { styles . metricValue }>{ latestResults . attainment_8_score . toFixed ( 1 )}</ div >
{ secondaryAvg . attainment_8_score != null && (
2026-07-20 07:19:22 +01:00
< div className = { styles . metricHint }> England avg : { secondaryAvg . attainment_8_score . toFixed ( 1 )}</ div >
2026-03-28 14:59:40 +00:00
)}
2026-03-25 10:06:36 +00:00
</ div >
)}
2026-03-28 14:59:40 +00:00
{ latestResults . progress_8_score !== null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }>
Progress 8
< MetricTooltip metricKey = "progress_8_score" />
</ div >
< div className = { ` ${ styles . metricValue } ${ progressClass ( latestResults . progress_8_score ) } ` }>
{ formatProgress ( latestResults . progress_8_score )}
</ div >
< div className = { styles . metricHint }> 0 = national average </ div >
2026-03-25 10:06:36 +00:00
</ div >
)}
2026-03-28 14:59:40 +00:00
{ latestResults . english_maths_standard_pass_pct !== null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }>
English & amp ; Maths Grade 4 +
< MetricTooltip metricKey = "english_maths_standard_pass_pct" />
</ div >
< div className = { styles . metricValue }>{ formatPercentage ( latestResults . english_maths_standard_pass_pct )}</ div >
{ secondaryAvg . english_maths_standard_pass_pct != null && (
2026-07-20 07:19:22 +01:00
< div className = { styles . metricHint }> England avg : { secondaryAvg . english_maths_standard_pass_pct . toFixed ( 0 )} % </ div >
2026-03-28 14:59:40 +00:00
)}
2026-03-25 10:06:36 +00:00
</ div >
)}
2026-03-28 14:59:40 +00:00
{ latestResults . english_maths_strong_pass_pct !== null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }>
English & amp ; Maths Grade 5 +
< MetricTooltip metricKey = "english_maths_strong_pass_pct" />
</ div >
< div className = { styles . metricValue }>{ formatPercentage ( latestResults . english_maths_strong_pass_pct )}</ div >
{ secondaryAvg . english_maths_strong_pass_pct != null && (
2026-07-20 07:19:22 +01:00
< div className = { styles . metricHint }> England avg : { secondaryAvg . english_maths_strong_pass_pct . toFixed ( 0 )} % </ div >
2026-03-28 14:59:40 +00:00
)}
2026-03-25 10:06:36 +00:00
</ div >
)}
</ div >
2026-03-28 14:59:40 +00:00
{ /* EBacc */ }
{( latestResults . ebacc_entry_pct !== null || latestResults . ebacc_standard_pass_pct !== null ) && (
<>
< h3 className = { styles . subSectionTitle } style = {{ marginTop : '1rem' }}>
English Baccalaureate ( EBacc )
< MetricTooltip metricKey = "ebacc_entry_pct" />
</ h3 >
< div className = { styles . metricTable }>
{ latestResults . ebacc_entry_pct !== null && (
< div className = { styles . metricRow }>
< span className = { styles . metricName }> Pupils entered for EBacc </ span >
< span className = { styles . metricValue }>{ formatPercentage ( latestResults . ebacc_entry_pct )}</ span >
</ div >
)}
{ latestResults . ebacc_standard_pass_pct !== null && (
< div className = { styles . metricRow }>
< span className = { styles . metricName }>
EBacc Grade 4 +
< MetricTooltip metricKey = "ebacc_standard_pass_pct" />
</ span >
< span className = { styles . metricValue }>{ formatPercentage ( latestResults . ebacc_standard_pass_pct )}</ span >
</ div >
)}
{ latestResults . ebacc_strong_pass_pct !== null && (
< div className = { styles . metricRow }>
< span className = { styles . metricName }>
EBacc Grade 5 +
< MetricTooltip metricKey = "ebacc_strong_pass_pct" />
</ span >
< span className = { styles . metricValue }>{ formatPercentage ( latestResults . ebacc_strong_pass_pct )}</ span >
</ div >
)}
2026-03-25 10:06:36 +00:00
</ div >
2026-03-28 14:59:40 +00:00
</>
)}
</>
2026-03-25 10:34:19 +00:00
)}
2026-03-25 10:06:36 +00:00
</ section >
)}
2026-07-01 11:15:54 +01:00
{ /* Admissions */ }
2026-03-25 10:06:36 +00:00
{ admissions && (
2026-03-25 10:34:19 +00:00
< section id = "admissions" className = { styles . card }>
2026-06-19 18:41:03 +01:00
< div className = { styles . admissionsHeader }>
< h2 className = { styles . sectionTitle }>
2026-07-01 11:15:54 +01:00
Admissions { ! showAdmissionsTrend && ` ( ${ formatAcademicYear ( admissions . year ) } )` }
2026-06-19 18:41:03 +01:00
</ h2 >
{ showAdmissionsTrend && (
< div className = { styles . admissionsSeg } role = "group" aria-label = "Admissions view" >
< button type = "button" aria-pressed = { admissionsView === 'year' } onClick = {() => setAdmissionsView ( 'year' )}>
This year
</ button >
< button type = "button" aria-pressed = { admissionsView === 'trend' } onClick = {() => setAdmissionsView ( 'trend' )}>
{ admissionsHistory . length } - year trend
</ button >
</ div >
)}
</ div >
2026-07-20 17:13:37 +01:00
{ /* 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 && (
< p className = { styles . sectionSubtitle }>
These figures are for { admissions . school_phase . toLowerCase ()} entry
{ /secondary/i . test ( admissions . school_phase ) ? ' (Year 7)' : /primary/i . test ( admissions . school_phase ) ? ' (Reception)' : '' }.
</ p >
)}
2026-04-14 10:01:19 +01:00
2026-06-19 18:41:03 +01:00
< div className = { styles . admissionsViewport }>
{ /* This-year Q&A */ }
< div className = { styles . admissionsViewYear } hidden = { showAdmissionsTrend && admissionsView !== 'year' }>
2026-06-30 16:07:54 +01:00
< dl className = { styles . admissionsTiles }>
2026-06-19 18:41:03 +01:00
{ admissions . places_offered != null && (
2026-06-30 16:07:54 +01:00
< div className = { styles . admissionsTile }>
< dd className = { styles . admissionsTileNum }>{ admissions . places_offered }</ dd >
< dt className = { styles . admissionsTileLabel }> Places offered </ dt >
2026-06-19 18:41:03 +01:00
</ div >
)}
{ admissions . first_preference_applications != null && (
2026-06-30 16:07:54 +01:00
< div className = { styles . admissionsTile }>
< dd className = { styles . admissionsTileNum }>{ admissions . first_preference_applications }</ dd >
< dt className = { styles . admissionsTileLabel }> Wanted it first </ dt >
2026-06-19 18:41:03 +01:00
</ div >
)}
{ admissions . first_preference_offer_pct != null && (
2026-06-30 16:07:54 +01:00
< div className = { ` ${ styles . admissionsTile } ${ styles . admissionsTileAccent } ` }>
< dd className = { styles . admissionsTileNum }>
2026-06-19 18:41:03 +01:00
{ admissions . first_preference_offers != null && admissions . first_preference_applications != null ? (
<>
{ admissions . first_preference_offers }
2026-06-30 16:07:54 +01:00
< span className = { styles . admissionsTileSub }>
of { admissions . first_preference_applications } · { formatPercentage ( admissions . first_preference_offer_pct )}
2026-06-19 18:41:03 +01:00
</ span >
</>
) : (
formatPercentage ( admissions . first_preference_offer_pct )
)}
</ dd >
2026-06-30 16:07:54 +01:00
< dt className = { styles . admissionsTileLabel }> Got their first choice </ dt >
2026-06-19 18:41:03 +01:00
</ div >
)}
{ admissions . total_applications != null && (
2026-06-30 16:07:54 +01:00
< div className = { styles . admissionsTile }>
< dd className = { styles . admissionsTileNum }>{ admissions . total_applications . toLocaleString ()}</ dd >
< dt className = { styles . admissionsTileLabel }> Applied in total </ dt >
2026-06-19 18:41:03 +01:00
</ div >
)}
</ dl >
2026-07-20 12:03:23 +01:00
{ admissionsSummary . chip && (
< p className = { styles . admissionsTrendSummary }>{ admissionsSummary . chip . text }</ p >
)}
2026-04-14 11:03:57 +01:00
</ div >
2026-06-19 18:41:03 +01:00
{ /* Multi-year trend */ }
{ showAdmissionsTrend && (
< div className = { styles . admissionsViewTrend } hidden = { admissionsView !== 'trend' }>
< div className = { styles . admissionsChartCap }> First - choice offer rate </ div >
2026-06-19 18:59:57 +01:00
< AdmissionsTrendChart history = { admissionsHistory } />
2026-06-19 18:41:03 +01:00
< p className = { styles . admissionsTrendSummary }>
This year ({ formatAcademicYear ( admissions . year )}),{ ' ' }
{ admissions . first_preference_applications != null && (
<>< strong >{ admissions . first_preference_applications }</ strong > families put it first for </>
2026-04-14 10:01:19 +01:00
)}
2026-06-19 18:41:03 +01:00
{ admissions . places_offered != null && <>< strong >{ admissions . places_offered }</ strong > places </>}
{ admissions . total_applications != null && ` — ${ admissions . total_applications . toLocaleString () } applications in total` }.
</ p >
2026-03-25 10:06:36 +00:00
</ div >
)}
2026-06-19 18:41:03 +01:00
</ div >
2026-03-25 10:06:36 +00:00
</ section >
)}
{ /* Pupils & Inclusion */ }
2026-03-25 10:34:19 +00:00
{ hasInclusionData && (
< section id = "inclusion" className = { styles . card }>
2026-03-25 10:06:36 +00:00
< h2 className = { styles . sectionTitle }> Pupils & amp ; Inclusion </ h2 >
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatGrid }>
2026-03-25 10:06:36 +00:00
{ latestResults ? . disadvantaged_pct != null && (
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatCard }>
< div className = { styles . heroStatLabel }> Eligible for pupil premium </ div >
< div className = { styles . heroStatValue }>
2026-04-08 15:13:13 +01:00
{ formatPercentage ( latestResults . disadvantaged_pct )}
{ primaryAvg . disadvantaged_pct != null && (
< DeltaChip value = { latestResults . disadvantaged_pct } baseline = { primaryAvg . disadvantaged_pct } unit = "pts" size = "sm" />
)}
</ div >
2026-07-20 12:03:23 +01:00
< div className = { styles . heroStatHint }> Pupils from disadvantaged backgrounds { primaryAvg . disadvantaged_pct != null ? ` · England avg: ${ primaryAvg . disadvantaged_pct . toFixed ( 0 ) } %` : '' }</ div >
2026-03-25 10:06:36 +00:00
</ div >
)}
{ latestResults ? . eal_pct != null && (
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatCard }>
< div className = { styles . heroStatLabel }>
2026-03-28 14:59:40 +00:00
English as an additional language
< MetricTooltip metricKey = "eal_pct" />
</ div >
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatValue }>
2026-04-13 21:22:24 +01:00
{ formatPercentage ( latestResults . eal_pct )}
{ primaryAvg . eal_pct != null && (
< DeltaChip value = { latestResults . eal_pct } baseline = { primaryAvg . eal_pct } unit = "pts" size = "sm" />
)}
</ div >
{ primaryAvg . eal_pct != null && (
2026-07-20 07:19:22 +01:00
< div className = { styles . heroStatHint }> England avg : { primaryAvg . eal_pct . toFixed ( 0 )} % </ div >
2026-04-13 21:22:24 +01:00
)}
2026-03-25 10:06:36 +00:00
</ div >
)}
{ latestResults ? . sen_support_pct != null && (
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatCard }>
< div className = { styles . heroStatLabel }>
2026-03-28 14:59:40 +00:00
Pupils receiving SEN support
< MetricTooltip metricKey = "sen_support_pct" />
</ div >
2026-04-14 14:31:31 +01:00
< div className = { styles . heroStatValue }>
2026-04-08 15:13:13 +01:00
{ formatPercentage ( latestResults . sen_support_pct )}
{ primaryAvg . sen_support_pct != null && (
< DeltaChip value = { latestResults . sen_support_pct } baseline = { primaryAvg . sen_support_pct } unit = "pts" size = "sm" />
)}
</ div >
{ primaryAvg . sen_support_pct != null && (
2026-07-20 07:19:22 +01:00
< div className = { styles . heroStatHint }> England avg : { primaryAvg . sen_support_pct . toFixed ( 0 )} % </ div >
2026-04-08 15:13:13 +01:00
)}
2026-03-25 10:06:36 +00:00
</ div >
)}
2026-04-17 22:52:59 +01:00
{ hasGenderSplit && (() => {
const female = census ! . female_pupils ! ;
const male = census ! . male_pupils ! ;
const girlsPct = Math . round (( female / ( female + male )) * 100 );
const boysPct = 100 - girlsPct ;
return (
< div className = { styles . heroStatCard }>
< div className = { styles . heroStatLabel }> Boys and girls </ div >
< div className = { styles . genderSplitValue }>
< span className = { styles . genderSplitGirls }>{ girlsPct } % </ span >
< span className = { styles . genderSplitLabel }> girls </ span >
< span className = { styles . genderSplitSep }> · </ span >
< span className = { styles . genderSplitBoys }>{ boysPct } % </ span >
< span className = { styles . genderSplitLabel }> boys </ span >
</ div >
< div
className = { styles . genderBar }
role = "img"
aria-label = { `Gender split: ${ girlsPct } % girls, ${ boysPct } % boys` }
>
< span className = { styles . genderBarGirls } style = {{ width : ` ${ girlsPct } %` }} />
< span className = { styles . genderBarBoys } style = {{ width : ` ${ boysPct } %` }} />
</ div >
< div className = { styles . heroStatHint }>
{ female . toLocaleString ()} girls , { male . toLocaleString ()} boys
</ div >
</ div >
);
})()}
2026-03-25 10:06:36 +00:00
</ div >
{ senDetail && (
<>
< h3 className = { styles . subSectionTitle }> Types of additional needs supported </ h3 >
< p className = { styles . sectionSubtitle }>
What proportion of pupils with additional needs have each type of support need .
</ p >
< div className = { styles . metricsGrid }>
{[
{ 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 }) => (
< div key = { label } className = { styles . metricCard }>
< div className = { styles . metricLabel }>{ label }</ div >
< div className = { styles . metricValue }>{ pct } % </ div >
</ div >
))}
</ div >
</>
)}
</ section >
)}
2026-03-25 10:34:19 +00:00
{ /* Results Over Time (merged: chart + historical table) */ }
2026-02-02 20:34:35 +00:00
{ yearlyData . length > 0 && (
2026-03-25 10:34:19 +00:00
< section id = "history" className = { styles . card }>
2026-03-25 10:06:36 +00:00
< h2 className = { styles . sectionTitle }> Results Over Time </ h2 >
2026-07-20 12:53:07 +01:00
{ 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 && (
<>
< h3 className = { styles . subSectionTitle }> Primary — KS2 SATs </ h3 >
< div className = { styles . chartContainer }>
< PerformanceChart
data = { yearlyData }
schoolName = { schoolInfo . school_name }
isSecondary = { false }
nationalRwmAvg = { primaryAvg . rwm_expected_pct ?? null }
nationalByYear = { nationalAvg ? . by_year }
/>
</ div >
</>
)}
{ hasKS4Results && (
<>
< h3 className = { styles . subSectionTitle } style = {{ marginTop : '1.5rem' }}> Secondary — GCSEs </ h3 >
< div className = { styles . chartContainer }>
< PerformanceChart
data = { yearlyData }
schoolName = { schoolInfo . school_name }
isSecondary = { true }
nationalAtt8Avg = { secondaryAvg . attainment_8_score ?? null }
nationalByYear = { nationalAvg ? . by_year }
/>
</ div >
</>
)}
</>
) : (
< div className = { styles . chartContainer }>
< PerformanceChart
data = { yearlyData }
schoolName = { schoolInfo . school_name }
isSecondary = { isSecondary }
nationalRwmAvg = { isPrimary ? ( primaryAvg . rwm_expected_pct ?? null ) : null }
nationalAtt8Avg = { isSecondary ? ( secondaryAvg . attainment_8_score ?? null ) : null }
nationalByYear = { nationalAvg ? . by_year }
/>
</ div >
)}
2026-03-25 10:34:19 +00:00
{ yearlyData . length > 1 && (
2026-04-13 21:22:24 +01:00
< details className = { styles . historyDisclosure }>
< summary className = { styles . historyToggle }> View raw year - by - year data </ summary >
2026-03-25 10:34:19 +00:00
< div className = { styles . tableWrapper }>
< table className = { styles . dataTable }>
< thead >
< tr >
< th > Year </ th >
2026-07-20 12:53:07 +01:00
{ isAllThrough ? (
<>
< th > RWM ( expected % )</ th >
< th > Exceeding ( % )</ th >
< th > Attainment 8 </ th >
< th > Progress 8 </ th >
< th > English & amp ; Maths Grade 4 + </ th >
</>
) : isSecondary ? (
2026-03-28 14:59:40 +00:00
<>
< th > Attainment 8 </ th >
< th > Progress 8 </ th >
< th > English & amp ; Maths Grade 4 + </ th >
< th > English & amp ; Maths Grade 5 + </ th >
</>
) : (
<>
< th > Reading , Writing & amp ; Maths ( expected % )</ th >
< th > Exceeding expected ( % )</ th >
< th > Reading Progress </ th >
< th > Writing Progress </ th >
< th > Maths Progress </ th >
</>
)}
2026-03-25 10:34:19 +00:00
</ tr >
</ thead >
< tbody >
{ yearlyData . map (( result ) => (
< tr key = { result . year }>
2026-03-27 18:30:37 +00:00
< td className = { styles . yearCell }>{ formatAcademicYear ( result . year )}</ td >
2026-07-20 12:53:07 +01:00
{ isAllThrough ? (
<>
< td >{ result . rwm_expected_pct !== null ? formatPercentage ( result . rwm_expected_pct ) : '-' }</ td >
< td >{ result . rwm_high_pct !== null ? formatPercentage ( result . rwm_high_pct ) : '-' }</ td >
< td >{ result . attainment_8_score !== null ? result . attainment_8_score . toFixed ( 1 ) : '-' }</ td >
< td >{ result . progress_8_score !== null ? formatProgress ( result . progress_8_score ) : '-' }</ td >
< td >{ result . english_maths_standard_pass_pct !== null ? formatPercentage ( result . english_maths_standard_pass_pct ) : '-' }</ td >
</>
) : isSecondary ? (
2026-03-28 14:59:40 +00:00
<>
< td >{ result . attainment_8_score !== null ? result . attainment_8_score . toFixed ( 1 ) : '-' }</ td >
< td >{ result . progress_8_score !== null ? formatProgress ( result . progress_8_score ) : '-' }</ td >
< td >{ result . english_maths_standard_pass_pct !== null ? formatPercentage ( result . english_maths_standard_pass_pct ) : '-' }</ td >
< td >{ result . english_maths_strong_pass_pct !== null ? formatPercentage ( result . english_maths_strong_pass_pct ) : '-' }</ td >
</>
) : (
<>
< td >{ result . rwm_expected_pct !== null ? formatPercentage ( result . rwm_expected_pct ) : '-' }</ td >
< td >{ result . rwm_high_pct !== null ? formatPercentage ( result . rwm_high_pct ) : '-' }</ td >
< td >{ result . reading_progress !== null ? formatProgress ( result . reading_progress ) : '-' }</ td >
< td >{ result . writing_progress !== null ? formatProgress ( result . writing_progress ) : '-' }</ td >
< td >{ result . maths_progress !== null ? formatProgress ( result . maths_progress ) : '-' }</ td >
</>
)}
2026-03-25 10:34:19 +00:00
</ tr >
))}
</ tbody >
</ table >
</ div >
2026-04-13 21:22:24 +01:00
</ details >
2026-03-25 10:34:19 +00:00
)}
2026-02-02 20:34:35 +00:00
</ section >
)}
2026-07-20 12:53:07 +01:00
{ /* Year 1 Phonics — primary-stage metric (pure primary + all-through) */ }
{ hasPhonics && showPrimaryContent && phonics && (
2026-07-01 09:51:46 +01:00
< section id = "phonics" className = { styles . card }>
< h2 className = { styles . sectionTitle }> Year 1 Phonics ({ formatAcademicYear ( phonics . year )})</ h2 >
< p className = { styles . sectionSubtitle }>
Phonics is a key early reading skill . Children are tested at the end of Year 1 .
</ p >
< div className = { styles . metricsGrid }>
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }> Passed the phonics check </ div >
< div className = { styles . metricValue }>{ formatPercentage ( phonics . year1_phonics_pct )}</ div >
< div className = { styles . metricHint }> Phonics is a key early reading skill tested at end of Year 1 </ div >
</ div >
{ phonics . year2_phonics_pct != null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }> Year 2 pupils who retook and passed </ div >
< div className = { styles . metricValue }>{ formatPercentage ( phonics . year2_phonics_pct )}</ div >
</ div >
)}
</ div >
</ section >
)}
{ /* School Life */ }
{ hasSchoolLife && (
< section id = "school-life" className = { styles . card }>
< h2 className = { styles . sectionTitle }> School Life </ h2 >
< div className = { styles . metricsGrid }>
{ census ? . class_size_avg != null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }> Average class size </ div >
< div className = { styles . metricValue }>{ census . class_size_avg . toFixed ( 1 )}</ div >
< div className = { styles . metricHint }> Average number of pupils per class </ div >
</ div >
)}
{ absenceData ? . overall_absence_rate != null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }>
Days missed ( overall absence )
< MetricTooltip metricKey = "overall_absence_pct" />
</ div >
< div className = { styles . metricValue }>{ formatPercentage ( absenceData . overall_absence_rate )}</ div >
{ primaryAvg . overall_absence_pct != null && (
2026-07-20 07:19:22 +01:00
< div className = { styles . metricHint }> England avg : ~ { primaryAvg . overall_absence_pct . toFixed ( 1 )} % </ div >
2026-07-01 09:51:46 +01:00
)}
</ div >
)}
{ absenceData ? . persistent_absence_rate != null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }>
Regularly missing school
< MetricTooltip metricKey = "persistent_absence_pct" />
</ div >
< div className = { styles . metricValue }>{ formatPercentage ( absenceData . persistent_absence_rate )}</ div >
{ primaryAvg . persistent_absence_pct != null && (
2026-07-20 07:19:22 +01:00
< div className = { styles . metricHint }> England avg : ~ { primaryAvg . persistent_absence_pct . toFixed ( 0 )} % </ div >
2026-07-01 09:51:46 +01:00
)}
</ div >
)}
</ div >
</ section >
)}
{ /* Local Area Context */ }
{ hasDeprivation && deprivation && (
< section id = "local-area" className = { styles . card }>
< h2 className = { styles . sectionTitle }>
Local Area Context
< MetricTooltip metricKey = "idaci_decile" />
</ h2 >
< div className = { styles . deprivationDots }>
{ Array . from ({ length : 10 }, ( _ , i ) => (
< div
key = { i }
className = { ` ${ styles . deprivationDot } ${ i < deprivation . idaci_decile ! ? styles . deprivationDotFilled : '' } ` }
title = { `Decile ${ i + 1 } ` }
/>
))}
</ div >
< div className = { styles . deprivationScaleLabel }>
< span > Most deprived </ span >
< span > Least deprived </ span >
</ div >
< p className = { styles . deprivationDesc }>{ deprivationDesc ( deprivation . idaci_decile ! )}</ p >
</ section >
)}
{ /* Finances */ }
{ hasFinance && finance && (
< section id = "finances" className = { styles . card }>
< h2 className = { styles . sectionTitle }> School Finances ({ formatAcademicYear ( finance . year )})</ h2 >
< p className = { styles . sectionSubtitle }>
Per - pupil spending shows how much the school has to spend on each child & apos ; s education .
</ p >
< div className = { styles . metricsGrid }>
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }> Total spend per pupil per year </ div >
< div className = { styles . metricValue }> £ { Math . round ( finance . per_pupil_spend ! ). toLocaleString ()}</ div >
< div className = { styles . metricHint }> How much the school has to spend on each pupil annually </ div >
</ div >
{ finance . teacher_cost_pct != null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }> Share of budget spent on teachers </ div >
< div className = { styles . metricValue }>{ finance . teacher_cost_pct . toFixed ( 1 )} % </ div >
</ div >
)}
{ finance . staff_cost_pct != null && (
< div className = { styles . metricCard }>
< div className = { styles . metricLabel }> Share of budget spent on all staff </ div >
< div className = { styles . metricValue }>{ finance . staff_cost_pct . toFixed ( 1 )} % </ div >
</ div >
)}
</ div >
</ section >
)}
2026-02-02 20:34:35 +00:00
</ div >
);
}