PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m48s
Review feedback: the rankings/search rows treated any exactly-0 attainment as not comparable regardless of school type, so a genuine 0 at a mainstream school rendered as "—" (looks like missing data) and dropped its delta — stricter and inconsistent with SchoolDetailView's placeholder check. - SchoolRow: match SchoolDetailView's ks2Placeholder signature — suppress only when ALL of RWM + reading + writing + maths are 0 (special/suppressed signature), not on a bare rwm === 0. A genuine 0% combined (some pupils met individual subjects but not all three) is not all-zero, so it stays comparable and shows its real figure + trend. - SecondarySchoolRow: Attainment 8 is a single 0–80 score with no subject breakdown to form an all-zero signature, so key off establishment type only (drop the bare att8 === 0 guard). A genuine (if extreme) 0.0 shows its value + LA delta. - For consistency, apply the same to the detail views: drop the bare attainment_8_score === 0 guard (KS4 keys off isSpecial only); KS2 keeps the all-four-subjects-zero placeholder signature. Special schools / PRUs / AP are still handled via isSpecialSchool everywhere. tsc clean; 108/108 unit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
1349 lines
64 KiB
TypeScript
1349 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, SenDetail, Phonics,
|
||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||
} from '@/lib/types';
|
||
import {
|
||
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
|
||
} from '@/lib/utils';
|
||
import { DeltaChip } from './DeltaChip';
|
||
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[];
|
||
senDetail: SenDetail | null;
|
||
phonics: Phonics | null;
|
||
deprivation: SchoolDeprivation | null;
|
||
finance: SchoolFinance | null;
|
||
}
|
||
|
||
export function SchoolDetailView({
|
||
schoolInfo, yearlyData, absenceData,
|
||
ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
|
||
}: SchoolDetailViewProps) {
|
||
const router = useRouter();
|
||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||
const isInComparison = isSelected(schoolInfo.urn);
|
||
|
||
const [activeSection, setActiveSection] = useState<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);
|
||
|
||
// Back returns to wherever the user came from; deep-links (no in-app history)
|
||
// fall back to search so the button never dead-ends or leaves the site.
|
||
const handleBack = () => {
|
||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||
router.back();
|
||
} else {
|
||
router.push('/search');
|
||
}
|
||
};
|
||
|
||
const scrollToTop = () => {
|
||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
const el = sectionLinksRef.current;
|
||
if (!el) return;
|
||
const update = () => {
|
||
const overflow = el.scrollWidth - el.clientWidth;
|
||
// No overflow → treat as "at end" so the fade is hidden.
|
||
if (overflow <= 1) {
|
||
setSectionNavAtEnd(true);
|
||
return;
|
||
}
|
||
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
|
||
};
|
||
update();
|
||
el.addEventListener('scroll', update, { passive: true });
|
||
window.addEventListener('resize', update);
|
||
return () => {
|
||
el.removeEventListener('scroll', update);
|
||
window.removeEventListener('resize', update);
|
||
};
|
||
}, []);
|
||
|
||
// Track whether the hero's "Add to Compare" button is still on screen.
|
||
useEffect(() => {
|
||
const el = heroActionsRef.current;
|
||
if (!el) return;
|
||
const obs = new IntersectionObserver(
|
||
([entry]) => setHeroCtaVisible(entry.isIntersecting),
|
||
{ rootMargin: '-64px 0px 0px 0px' },
|
||
);
|
||
obs.observe(el);
|
||
return () => obs.disconnect();
|
||
}, []);
|
||
|
||
// Close the "All ▾" menu on Escape.
|
||
useEffect(() => {
|
||
if (!sectionsOpen) return;
|
||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); };
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [sectionsOpen]);
|
||
|
||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||
|
||
// Phase detection. All-through schools cover BOTH key stages, so they are
|
||
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
||
// have KS4 data) but isAllThrough gates the primary-only content (phonics,
|
||
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
||
const phase = schoolInfo.phase ?? '';
|
||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
||
const isPrimary = !isSecondary;
|
||
// Primary-stage content shows for pure-primary AND all-through schools.
|
||
const showPrimaryContent = isPrimary || isAllThrough;
|
||
|
||
// National averages (fetched dynamically so they stay current)
|
||
const [nationalAvg, setNationalAvg] = useState<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)
|
||
|| senDetail != null
|
||
|| hasGenderSplit;
|
||
|
||
const hasSchoolLife = absenceData != null || census?.class_size_avg != null;
|
||
const hasPhonics = phonics != null && phonics.year1_phonics_pct != null;
|
||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||
|
||
// Determine whether this school has KS2 or KS4 results to show
|
||
const hasKS2Results = latestResults != null && latestResults.rwm_expected_pct != null;
|
||
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
||
const hasAnyResults = hasKS2Results || hasKS4Results;
|
||
|
||
// Special schools / PRUs / AP: their pupils sit the same tests but very few
|
||
// reach the mainstream "expected standard", so a 0% headline and an England
|
||
// comparison portray them as failing against a benchmark that doesn't fit.
|
||
const isSpecial = isSpecialSchool(schoolInfo);
|
||
// Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a
|
||
// special/suppressed signature) is a placeholder, not a real result. This
|
||
// needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0%
|
||
// combined (some pupils met individual subjects but not all three) stays
|
||
// comparable. Attainment 8 is a single 0–80 score with no subject breakdown
|
||
// to form such a signature, so KS4 keys off establishment type only — a
|
||
// genuine (if extreme) 0.0 still shows its real figure and comparison.
|
||
const ks2Placeholder = latestResults != null
|
||
&& latestResults.rwm_expected_pct === 0
|
||
&& (latestResults.reading_expected_pct ?? 0) === 0
|
||
&& (latestResults.writing_expected_pct ?? 0) === 0
|
||
&& (latestResults.maths_expected_pct ?? 0) === 0;
|
||
// Whether to drop the England-average deltas / national markers / "below"
|
||
// framing on the attainment measures.
|
||
const suppressKs2Comparison = isSpecial || ks2Placeholder;
|
||
const suppressKs4Comparison = isSpecial;
|
||
|
||
// Build section nav items dynamically — only sections with data.
|
||
// Order is engagement-led (from section_nav_used analytics): the most-sought
|
||
// sections — results, admissions, inclusion, history — sit near the top,
|
||
// after the recognised Ofsted badge; low-demand context sections stay last.
|
||
const navItems: { id: string; label: string }[] = [];
|
||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||
if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' });
|
||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
||
if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' });
|
||
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||
|
||
// Track active section as user scrolls
|
||
useEffect(() => {
|
||
const ids = navItems.map(n => n.id);
|
||
if (!ids.length) return;
|
||
|
||
const observers: IntersectionObserver[] = [];
|
||
const ratioMap: Record<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>
|
||
)}
|
||
</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>
|
||
)}
|
||
<div className={styles.headerDetails}>
|
||
{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>
|
||
)}
|
||
</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}>
|
||
{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}>
|
||
{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>
|
||
|
||
{/* Special schools sit the same national assessments but teach pupils
|
||
with SEND, so very few reach the mainstream "expected standard".
|
||
Explain that up front and drop the England comparison below, so a
|
||
0% headline never reads as a failing grade against a benchmark
|
||
that doesn't fit the school. */}
|
||
{isSpecial && (
|
||
<div className={styles.specialNote} role="note">
|
||
<strong>This is a special school.</strong> Its pupils have special educational
|
||
needs and work towards individual targets. They sit the same national tests, but
|
||
very few reach the mainstream “expected standard” these measures report,
|
||
so a comparison with the England average isn’t a meaningful guide to the
|
||
school. Where available, the progress pupils make (below) is a fairer measure.
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 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,
|
||
},
|
||
{
|
||
name: 'Writing',
|
||
expectedPct: latestResults.writing_expected_pct,
|
||
exceedingPct: latestResults.writing_high_pct,
|
||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
|
||
},
|
||
{
|
||
name: 'Maths',
|
||
expectedPct: latestResults.maths_expected_pct,
|
||
exceedingPct: latestResults.maths_high_pct,
|
||
nationalExpectedPct: suppressKs2Comparison ? null : 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)}`}>
|
||
{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>
|
||
|
||
<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>
|
||
{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>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
{/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
|
||
{hasPhonics && showPrimaryContent && phonics && (
|
||
<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 && (
|
||
<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>
|
||
);
|
||
}
|