PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 52s
Religious character now sits with the other labelled header details (near
County / Constituency) as "Religious character: <denomination>" ("None" for
Does not apply / None), instead of as an identity chip.
On mobile/tablet (≤768px) the header details block was hidden outright; it's
now collapsed behind a "Show all details" toggle that reveals the full block
(headteacher, website, pupils, trust, phone, religious character, county,
constituency). Applied to both the primary and secondary detail views for
parity. Extends the e2e journey with the mobile toggle behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1344 lines
64 KiB
TypeScript
1344 lines
64 KiB
TypeScript
/**
|
||
* SchoolDetailView Component
|
||
* Displays comprehensive school information with performance charts
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import dynamic from 'next/dynamic';
|
||
import { useComparison } from '@/hooks/useComparison';
|
||
import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
|
||
import { MetricTooltip } from './MetricTooltip';
|
||
import type {
|
||
School, SchoolResult, AbsenceData,
|
||
OfstedInspection, SchoolCensus,
|
||
SchoolAdmissions,
|
||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||
} from '@/lib/types';
|
||
import {
|
||
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
|
||
} from '@/lib/utils';
|
||
import { DeltaChip } from './DeltaChip';
|
||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||
import { summariseAdmissions } from '@/lib/compareLogic';
|
||
|
||
const PerformanceChart = dynamic(
|
||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||
{ ssr: false },
|
||
);
|
||
const SatsChart = dynamic(() => import('./SatsChart'), { ssr: false });
|
||
const AdmissionsTrendChart = dynamic(() => import('./AdmissionsTrendChart'), { ssr: false });
|
||
import { track, getNavigationSource } from '@/lib/analytics';
|
||
import styles from './SchoolDetailView.module.css';
|
||
|
||
const OFSTED_LABELS: Record<number, string> = {
|
||
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
||
};
|
||
|
||
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' },
|
||
];
|
||
|
||
|
||
function progressClass(val: number | null | undefined): string {
|
||
if (val == null) return '';
|
||
if (val > 0) return styles.progressPositive;
|
||
if (val < 0) return styles.progressNegative;
|
||
return '';
|
||
}
|
||
|
||
interface SchoolDetailViewProps {
|
||
schoolInfo: School;
|
||
yearlyData: SchoolResult[];
|
||
absenceData: AbsenceData | null;
|
||
ofsted: OfstedInspection | null;
|
||
census: SchoolCensus | null;
|
||
admissions: SchoolAdmissions | null;
|
||
admissionsHistory: SchoolAdmissions[];
|
||
deprivation: SchoolDeprivation | null;
|
||
finance: SchoolFinance | null;
|
||
}
|
||
|
||
export function SchoolDetailView({
|
||
schoolInfo, yearlyData, absenceData,
|
||
ofsted, census, admissions, admissionsHistory, deprivation, finance,
|
||
}: SchoolDetailViewProps) {
|
||
const router = useRouter();
|
||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||
const isInComparison = isSelected(schoolInfo.urn);
|
||
|
||
const [activeSection, setActiveSection] = useState<string>('');
|
||
const [admissionsView, setAdmissionsView] = useState<'year' | 'trend'>('year');
|
||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
||
// Banded interpretation of the first-choice offer rate ("More than half of
|
||
// first choices missed out" etc.) — the same banding the compare screen
|
||
// uses, so a low offer rate reads as how severe it actually is.
|
||
const admissionsSummary = summariseAdmissions(admissions);
|
||
// Only the section links scroll horizontally; Back and "All" stay pinned.
|
||
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
|
||
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||
// Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves.
|
||
const heroActionsRef = useRef<HTMLDivElement | null>(null);
|
||
const [heroCtaVisible, setHeroCtaVisible] = useState(true);
|
||
// Hero map — the "View on map" link opens its fullscreen view.
|
||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||
// "All ▾" jump menu listing every section.
|
||
const [sectionsOpen, setSectionsOpen] = useState(false);
|
||
// Header details (headteacher, contact, trust, area) collapse behind a
|
||
// "Show all details" link on mobile/tablet, where they're below the fold.
|
||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||
|
||
// Back returns to wherever the user came from; deep-links (no in-app history)
|
||
// fall back to search so the button never dead-ends or leaves the site.
|
||
const handleBack = () => {
|
||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||
router.back();
|
||
} else {
|
||
router.push('/search');
|
||
}
|
||
};
|
||
|
||
const scrollToTop = () => {
|
||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
const el = sectionLinksRef.current;
|
||
if (!el) return;
|
||
const update = () => {
|
||
const overflow = el.scrollWidth - el.clientWidth;
|
||
// No overflow → treat as "at end" so the fade is hidden.
|
||
if (overflow <= 1) {
|
||
setSectionNavAtEnd(true);
|
||
return;
|
||
}
|
||
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
|
||
};
|
||
update();
|
||
el.addEventListener('scroll', update, { passive: true });
|
||
window.addEventListener('resize', update);
|
||
return () => {
|
||
el.removeEventListener('scroll', update);
|
||
window.removeEventListener('resize', update);
|
||
};
|
||
}, []);
|
||
|
||
// Track whether the hero's "Add to Compare" button is still on screen.
|
||
useEffect(() => {
|
||
const el = heroActionsRef.current;
|
||
if (!el) return;
|
||
const obs = new IntersectionObserver(
|
||
([entry]) => setHeroCtaVisible(entry.isIntersecting),
|
||
{ rootMargin: '-64px 0px 0px 0px' },
|
||
);
|
||
obs.observe(el);
|
||
return () => obs.disconnect();
|
||
}, []);
|
||
|
||
// Close the "All ▾" menu on Escape.
|
||
useEffect(() => {
|
||
if (!sectionsOpen) return;
|
||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); };
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [sectionsOpen]);
|
||
|
||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||
|
||
// Phase detection. All-through schools cover BOTH key stages, so they are
|
||
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
||
// have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs,
|
||
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
||
const phase = schoolInfo.phase ?? '';
|
||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
||
const isPrimary = !isSecondary;
|
||
|
||
// 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 ?? {};
|
||
|
||
const handleComparisonToggle = () => {
|
||
if (isInComparison) {
|
||
removeSchool(schoolInfo.urn);
|
||
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||
} else {
|
||
addSchool(schoolInfo);
|
||
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||
}
|
||
};
|
||
|
||
// Page-view event with funnel attribution. Fires once per mount.
|
||
useEffect(() => {
|
||
track('school_viewed', {
|
||
urn: schoolInfo.urn,
|
||
phase: phase || 'unknown',
|
||
local_authority: schoolInfo.local_authority || 'unknown',
|
||
from: getNavigationSource(),
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [schoolInfo.urn]);
|
||
|
||
const deprivationDesc = (decile: number) => {
|
||
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
||
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
||
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
||
};
|
||
|
||
// Gender split availability (only meaningful for Mixed schools with census data)
|
||
const isMixedSchool = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
||
const hasGenderSplit = isMixedSchool
|
||
&& census?.female_pupils != null
|
||
&& census?.male_pupils != null
|
||
&& (census.female_pupils + census.male_pupils) > 0;
|
||
|
||
// Guard for Pupils & Inclusion — only show if at least one metric is available
|
||
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|
||
|| (latestResults?.eal_pct != null)
|
||
|| (latestResults?.sen_support_pct != null)
|
||
|| hasGenderSplit;
|
||
|
||
const hasSchoolLife = absenceData != null;
|
||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||
|
||
// Determine whether this school has KS2 or KS4 results to show
|
||
const hasKS2Results = latestResults != null && latestResults.rwm_expected_pct != null;
|
||
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
||
const hasAnyResults = hasKS2Results || hasKS4Results;
|
||
|
||
// Special schools / PRUs / AP: their pupils sit the same tests but very few
|
||
// reach the mainstream "expected standard", so a 0% headline and an England
|
||
// comparison portray them as failing against a benchmark that doesn't fit.
|
||
const isSpecial = isSpecialSchool(schoolInfo);
|
||
// Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a
|
||
// special/suppressed signature) is a placeholder, not a real result. This
|
||
// needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0%
|
||
// combined (some pupils met individual subjects but not all three) stays
|
||
// comparable. Attainment 8 is a single 0–80 score with no subject breakdown
|
||
// to form such a signature, so KS4 keys off establishment type only — a
|
||
// genuine (if extreme) 0.0 still shows its real figure and comparison.
|
||
const ks2Placeholder = latestResults != null
|
||
&& latestResults.rwm_expected_pct === 0
|
||
&& (latestResults.reading_expected_pct ?? 0) === 0
|
||
&& (latestResults.writing_expected_pct ?? 0) === 0
|
||
&& (latestResults.maths_expected_pct ?? 0) === 0;
|
||
// Whether to drop the England-average deltas / national markers / "below"
|
||
// framing on the attainment measures.
|
||
const suppressKs2Comparison = isSpecial || ks2Placeholder;
|
||
const suppressKs4Comparison = isSpecial;
|
||
|
||
// Build section nav items dynamically — only sections with data.
|
||
// Order is engagement-led (from section_nav_used analytics): the most-sought
|
||
// sections — results, admissions, inclusion, history — sit near the top,
|
||
// after the recognised Ofsted badge; low-demand context sections stay last.
|
||
const navItems: { id: string; label: string }[] = [];
|
||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||
if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' });
|
||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
||
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||
|
||
// Track active section as user scrolls
|
||
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(',')]);
|
||
|
||
// A report card is identified by the presence of report-card area
|
||
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||
// the old `framework === 'ReportCard'` test never matched and report cards
|
||
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||
const isReportCard = !!(
|
||
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
||
);
|
||
// A report card is dated by its own inspection (rc_inspection_date); the
|
||
// legacy inspection_date belongs to an older inspection and must never
|
||
// date a report card (report cards exist only from Nov 2025).
|
||
const ofstedInspectedDate = isReportCard
|
||
? ofsted?.rc_inspection_date ?? null
|
||
: ofsted?.inspection_date ?? null;
|
||
|
||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||
const oeifAllSameGrade =
|
||
!!ofsted &&
|
||
!isReportCard &&
|
||
oeifAreas.length >= 3 &&
|
||
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||
|
||
// Label shown in the mobile "section" menu button — the section in view.
|
||
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
{/* 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>
|
||
|
||
{/* 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!} />
|
||
)}
|
||
<div className={styles.headerContent}>
|
||
<div className={styles.titleSection}>
|
||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||
<div className={styles.meta}>
|
||
{schoolInfo.local_authority && (
|
||
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
|
||
)}
|
||
{schoolInfo.school_type && (
|
||
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
||
)}
|
||
{isAllThrough && (
|
||
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
||
)}
|
||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||
)}
|
||
{schoolInfo.age_range && (
|
||
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||
)}
|
||
{schoolInfo.nursery_provision && (
|
||
<span className={styles.metaItem}>Nursery</span>
|
||
)}
|
||
{schoolInfo.has_sixth_form && (
|
||
<span className={styles.metaItem}>Sixth form</span>
|
||
)}
|
||
</div>
|
||
{isProposedToClose(schoolInfo) && (
|
||
<div className={styles.closingStrip} role="note">
|
||
<strong>⚠ Proposed to close</strong> — this school is proposed for closure,
|
||
check with the local authority before applying.
|
||
</div>
|
||
)}
|
||
{schoolInfo.address && (
|
||
<p className={styles.address}>
|
||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||
{hasLocation && (
|
||
<>
|
||
{' · '}
|
||
<button
|
||
type="button"
|
||
className={styles.mapLink}
|
||
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
|
||
>
|
||
View on map ↗
|
||
</button>
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={styles.detailsToggle}
|
||
aria-expanded={detailsOpen}
|
||
aria-controls="school-header-details"
|
||
onClick={() => setDetailsOpen((o) => !o)}
|
||
>
|
||
{detailsOpen ? 'Hide details' : 'Show all details'}
|
||
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
|
||
</button>
|
||
<div
|
||
id="school-header-details"
|
||
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
|
||
>
|
||
{schoolInfo.headteacher_name && (
|
||
<span className={styles.headerDetail}>
|
||
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
||
</span>
|
||
)}
|
||
{schoolInfo.website && (
|
||
<span className={styles.headerDetail}>
|
||
<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"
|
||
>
|
||
School website ↗
|
||
</a>
|
||
</span>
|
||
)}
|
||
{(() => {
|
||
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>
|
||
);
|
||
})()}
|
||
{schoolInfo.trust_name && (
|
||
<span className={styles.headerDetail}>
|
||
Part of <strong>{schoolInfo.trust_name}</strong>
|
||
</span>
|
||
)}
|
||
{schoolInfo.telephone && (
|
||
<span className={styles.headerDetail}>
|
||
<strong>Phone:</strong>{' '}
|
||
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
||
{schoolInfo.telephone}
|
||
</a>
|
||
</span>
|
||
)}
|
||
{schoolInfo.religious_denomination && (
|
||
<span className={styles.headerDetail}>
|
||
<strong>Religious character:</strong>{' '}
|
||
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
||
? 'None'
|
||
: schoolInfo.religious_denomination}
|
||
</span>
|
||
)}
|
||
{schoolInfo.county && (
|
||
<span className={styles.headerDetail}>
|
||
<strong>County:</strong> {schoolInfo.county}
|
||
</span>
|
||
)}
|
||
{schoolInfo.parliamentary_constituency && (
|
||
<span className={styles.headerDetail}>
|
||
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className={styles.actions} ref={heroActionsRef}>
|
||
<button
|
||
onClick={handleComparisonToggle}
|
||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
||
>
|
||
{/* 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>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Sticky Section Navigation — docks under the global header */}
|
||
<nav className={styles.sectionNav} aria-label="Page sections">
|
||
<button onClick={scrollToTop} className={styles.sectionNavBack} aria-label="Back to top">
|
||
<span aria-hidden="true">↑</span>
|
||
<span className={styles.sectionNavBackLabel}>Top</span>
|
||
</button>
|
||
|
||
{/* Desktop: scrolling section links */}
|
||
<div
|
||
ref={sectionLinksRef}
|
||
className={`${styles.sectionNavLinks}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
|
||
>
|
||
{navItems.map(({ id, label }) => (
|
||
<a
|
||
key={id}
|
||
href={`#${id}`}
|
||
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
||
onClick={() => track('section_nav_used', { section: id })}
|
||
>
|
||
{label}
|
||
</a>
|
||
))}
|
||
</div>
|
||
|
||
{/* Mobile: a single "section" menu button that opens the jump sheet */}
|
||
{navItems.length > 0 && (
|
||
<button
|
||
type="button"
|
||
className={styles.sectionNavMenu}
|
||
aria-haspopup="menu"
|
||
aria-expanded={sectionsOpen}
|
||
onClick={() => setSectionsOpen((o) => !o)}
|
||
>
|
||
<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>
|
||
</button>
|
||
)}
|
||
|
||
{/* The hero's Compare CTA, carried in once it scrolls out of view.
|
||
Desktop shows a labelled pill; mobile a compact icon. */}
|
||
{!heroCtaVisible && (
|
||
<>
|
||
<button
|
||
onClick={handleComparisonToggle}
|
||
className={`${styles.sectionNavCompare}${isInComparison ? ` ${styles.sectionNavCompareIn}` : ''}`}
|
||
>
|
||
{isInComparison ? '✓ Comparing' : '+ Compare'}
|
||
</button>
|
||
<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>
|
||
</>
|
||
)}
|
||
</nav>
|
||
|
||
{/* Ofsted Rating / Report Card */}
|
||
{ofsted && (
|
||
<section id="ofsted" className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>
|
||
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||
{ofstedInspectedDate && (
|
||
<span className={styles.ofstedDate}>
|
||
Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||
</span>
|
||
)}
|
||
<a
|
||
href={`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${schoolInfo.urn}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={styles.ofstedReportLink}
|
||
data-umami-event="external_link_clicked"
|
||
data-umami-event-target="ofsted"
|
||
>
|
||
Ofsted reports ↗
|
||
</a>
|
||
</h2>
|
||
|
||
{isReportCard ? (
|
||
/* ── 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} ${styles.gradeGrid}`}>
|
||
{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>
|
||
)}
|
||
{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;
|
||
})}
|
||
</div>
|
||
</>
|
||
) : (
|
||
/* ── 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}>
|
||
{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.'}
|
||
</p>
|
||
{oeifAllSameGrade ? (
|
||
<p className={styles.ofstedAllSame}>
|
||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||
</p>
|
||
) : (
|
||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||
{oeifAreas.map(({ label, value }) => (
|
||
<div key={label} className={styles.metricCard}>
|
||
<div className={styles.metricLabel}>{label}</div>
|
||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||
{OFSTED_LABELS[value]}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* Results Section (SATs for primary, GCSEs for secondary) */}
|
||
{hasAnyResults && latestResults && (
|
||
<section id="results" className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>
|
||
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
||
</h2>
|
||
<p className={styles.sectionSubtitle}>
|
||
{isSpecial
|
||
? (isSecondary
|
||
? 'GCSE results for Year 11 pupils.'
|
||
: 'End-of-primary-school tests taken by Year 6 pupils.')
|
||
: isAllThrough
|
||
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
|
||
: isSecondary
|
||
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
|
||
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
|
||
</p>
|
||
|
||
{/* Explains up front why the England comparison is dropped below, so
|
||
a 0% headline never reads as a failing grade against a benchmark
|
||
that doesn't fit. Type-aware copy (special vs PRU vs AP). */}
|
||
<SpecialSchoolNote school={schoolInfo} />
|
||
|
||
{/* ── Primary / KS2 content ── */}
|
||
{hasKS2Results && (
|
||
<>
|
||
{isAllThrough && (
|
||
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs (Year 6)</h3>
|
||
)}
|
||
<div className={styles.heroStatGrid}>
|
||
{latestResults.rwm_expected_pct !== null && (
|
||
<div className={styles.heroStatCard}>
|
||
<div className={styles.heroStatLabel}>
|
||
Reading, Writing & Maths combined
|
||
<MetricTooltip metricKey="rwm_expected_pct" />
|
||
</div>
|
||
<div className={styles.heroStatValue}>
|
||
{formatPercentage(latestResults.rwm_expected_pct)}
|
||
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||
<DeltaChip
|
||
value={latestResults.rwm_expected_pct}
|
||
baseline={primaryAvg.rwm_expected_pct}
|
||
unit="pts"
|
||
size="sm"
|
||
/>
|
||
)}
|
||
</div>
|
||
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{latestResults.rwm_high_pct !== null && (
|
||
<div className={styles.heroStatCard}>
|
||
<div className={styles.heroStatLabel}>
|
||
Exceeding expected level (Reading, Writing & Maths)
|
||
<MetricTooltip metricKey="rwm_high_pct" />
|
||
</div>
|
||
<div className={styles.heroStatValue}>
|
||
{formatPercentage(latestResults.rwm_high_pct)}
|
||
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||
<DeltaChip
|
||
value={latestResults.rwm_high_pct}
|
||
baseline={primaryAvg.rwm_high_pct}
|
||
unit="pts"
|
||
size="sm"
|
||
/>
|
||
)}
|
||
</div>
|
||
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{!suppressKs2Comparison &&
|
||
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>
|
||
)}
|
||
|
||
{/* All-zero placeholder rows (special / suppressed) would render as
|
||
three empty bars against the national markers — misleading, so
|
||
skip the chart. For a special school with some non-zero
|
||
subjects, keep the bars but drop the national markers. */}
|
||
{!ks2Placeholder && (
|
||
<SatsChart
|
||
subjects={[
|
||
{
|
||
name: 'Reading',
|
||
expectedPct: latestResults.reading_expected_pct,
|
||
exceedingPct: latestResults.reading_high_pct,
|
||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
|
||
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
|
||
},
|
||
{
|
||
name: 'Writing',
|
||
expectedPct: latestResults.writing_expected_pct,
|
||
exceedingPct: latestResults.writing_high_pct,
|
||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
|
||
// Writing's higher level is teacher-assessed "greater depth".
|
||
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
|
||
},
|
||
{
|
||
name: 'Maths',
|
||
expectedPct: latestResults.maths_expected_pct,
|
||
exceedingPct: latestResults.maths_high_pct,
|
||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
|
||
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_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)}`}>
|
||
{formatProgress(latestResults.reading_progress)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{latestResults.writing_progress != null && (
|
||
<div className={styles.progressScoreItem}>
|
||
<span className={styles.progressScoreLabel}>Writing</span>
|
||
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.writing_progress)}`}>
|
||
{formatProgress(latestResults.writing_progress)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{latestResults.maths_progress != null && (
|
||
<div className={styles.progressScoreItem}>
|
||
<span className={styles.progressScoreLabel}>Maths</span>
|
||
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.maths_progress)}`}>
|
||
{formatProgress(latestResults.maths_progress)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(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 && (
|
||
<>
|
||
{isAllThrough && (
|
||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs (Year 11)</h3>
|
||
)}
|
||
<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>
|
||
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
|
||
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{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>
|
||
</div>
|
||
)}
|
||
{latestResults.english_maths_standard_pass_pct !== null && (
|
||
<div className={styles.metricCard}>
|
||
<div className={styles.metricLabel}>
|
||
English & Maths Grade 4+
|
||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||
</div>
|
||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{latestResults.english_maths_strong_pass_pct !== null && (
|
||
<div className={styles.metricCard}>
|
||
<div className={styles.metricLabel}>
|
||
English & Maths Grade 5+
|
||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||
</div>
|
||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* Admissions */}
|
||
{admissions && (
|
||
<section id="admissions" className={styles.card}>
|
||
<div className={styles.admissionsHeader}>
|
||
<h2 className={styles.sectionTitle}>
|
||
Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}
|
||
</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>
|
||
{/* 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>
|
||
)}
|
||
|
||
<div className={styles.admissionsViewport}>
|
||
{/* This-year Q&A */}
|
||
<div className={styles.admissionsViewYear} hidden={showAdmissionsTrend && admissionsView !== 'year'}>
|
||
<dl className={styles.admissionsTiles}>
|
||
{admissions.places_offered != null && (
|
||
<div className={styles.admissionsTile}>
|
||
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
||
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
||
</div>
|
||
)}
|
||
{admissions.first_preference_applications != null && (
|
||
<div className={styles.admissionsTile}>
|
||
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
||
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
||
</div>
|
||
)}
|
||
{admissions.first_preference_offer_pct != null && (
|
||
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
||
<dd className={styles.admissionsTileNum}>
|
||
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
||
<>
|
||
{admissions.first_preference_offers}
|
||
<span className={styles.admissionsTileSub}>
|
||
of {admissions.first_preference_applications} · {formatPercentage(admissions.first_preference_offer_pct)}
|
||
</span>
|
||
</>
|
||
) : (
|
||
formatPercentage(admissions.first_preference_offer_pct)
|
||
)}
|
||
</dd>
|
||
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
|
||
</div>
|
||
)}
|
||
{admissions.total_applications != null && (
|
||
<div className={styles.admissionsTile}>
|
||
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
||
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
||
</div>
|
||
)}
|
||
</dl>
|
||
{admissionsSummary.chip && (
|
||
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Multi-year trend */}
|
||
{showAdmissionsTrend && (
|
||
<div className={styles.admissionsViewTrend} hidden={admissionsView !== 'trend'}>
|
||
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
||
<AdmissionsTrendChart history={admissionsHistory} />
|
||
<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 </>
|
||
)}
|
||
{admissions.places_offered != null && <><strong>{admissions.places_offered}</strong> places</>}
|
||
{admissions.total_applications != null && ` — ${admissions.total_applications.toLocaleString()} applications in total`}.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Pupils & Inclusion */}
|
||
{hasInclusionData && (
|
||
<section id="inclusion" className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>Pupils & Inclusion</h2>
|
||
<div className={styles.heroStatGrid}>
|
||
{latestResults?.disadvantaged_pct != null && (
|
||
<div className={styles.heroStatCard}>
|
||
<div className={styles.heroStatLabel}>Eligible for pupil premium</div>
|
||
<div className={styles.heroStatValue}>
|
||
{formatPercentage(latestResults.disadvantaged_pct)}
|
||
{primaryAvg.disadvantaged_pct != null && (
|
||
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
||
)}
|
||
</div>
|
||
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
||
</div>
|
||
)}
|
||
{latestResults?.eal_pct != null && (
|
||
<div className={styles.heroStatCard}>
|
||
<div className={styles.heroStatLabel}>
|
||
English as an additional language
|
||
<MetricTooltip metricKey="eal_pct" />
|
||
</div>
|
||
<div className={styles.heroStatValue}>
|
||
{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 && (
|
||
<div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{latestResults?.sen_support_pct != null && (
|
||
<div className={styles.heroStatCard}>
|
||
<div className={styles.heroStatLabel}>
|
||
Pupils receiving SEN support
|
||
<MetricTooltip metricKey="sen_support_pct" />
|
||
</div>
|
||
<div className={styles.heroStatValue}>
|
||
{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 && (
|
||
<div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{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>
|
||
);
|
||
})()}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Results Over Time (merged: chart + historical table) */}
|
||
{yearlyData.length > 0 && (
|
||
<section id="history" className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
||
{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={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
|
||
nationalByYear={suppressKs2Comparison ? undefined : 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={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
|
||
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className={styles.chartContainer}>
|
||
<PerformanceChart
|
||
data={yearlyData}
|
||
schoolName={schoolInfo.school_name}
|
||
isSecondary={isSecondary}
|
||
nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
||
nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
|
||
nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
|
||
/>
|
||
</div>
|
||
)}
|
||
{yearlyData.length > 1 && (
|
||
<details className={styles.historyDisclosure}>
|
||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||
<div className={styles.tableWrapper}>
|
||
<table className={styles.dataTable}>
|
||
<thead>
|
||
<tr>
|
||
<th>Year</th>
|
||
{isAllThrough ? (
|
||
<>
|
||
<th>RWM (expected %)</th>
|
||
<th>Exceeding (%)</th>
|
||
<th>Attainment 8</th>
|
||
<th>Progress 8</th>
|
||
<th>English & Maths Grade 4+</th>
|
||
</>
|
||
) : isSecondary ? (
|
||
<>
|
||
<th>Attainment 8</th>
|
||
<th>Progress 8</th>
|
||
<th>English & Maths Grade 4+</th>
|
||
<th>English & Maths Grade 5+</th>
|
||
</>
|
||
) : (
|
||
<>
|
||
<th>Reading, Writing & Maths (expected %)</th>
|
||
<th>Exceeding expected (%)</th>
|
||
<th>Reading Progress</th>
|
||
<th>Writing Progress</th>
|
||
<th>Maths Progress</th>
|
||
</>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{yearlyData.map((result) => (
|
||
<tr key={result.year}>
|
||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||
{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 ? (
|
||
<>
|
||
<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>
|
||
</>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
)}
|
||
</section>
|
||
)}
|
||
{/* School Life */}
|
||
{hasSchoolLife && (
|
||
<section id="school-life" className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>School Life</h2>
|
||
<div className={styles.metricsGrid}>
|
||
{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 && (
|
||
<div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
|
||
)}
|
||
</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 && (
|
||
<div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
|
||
)}
|
||
</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'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>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|