Files
school_compare/nextjs-app/components/SchoolDetailView.tsx
T

1349 lines
64 KiB
TypeScript
Raw Normal View History

/**
* 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 080 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 &amp; secondary)</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;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&apos; 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 &ldquo;expected standard&rdquo; these measures report,
so a comparison with the England average isn&rsquo;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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; Maths Grade 4+</th>
</>
) : isSecondary ? (
<>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>English &amp; Maths Grade 4+</th>
<th>English &amp; Maths Grade 5+</th>
</>
) : (
<>
<th>Reading, Writing &amp; Maths (expected %)</th>
<th>Exceeding expected (%)</th>
<th>Reading Progress</th>
<th>Writing Progress</th>
<th>Maths Progress</th>
</>
)}
</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&apos;s education.
</p>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total spend per pupil per year</div>
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
</div>
{finance.teacher_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
</div>
)}
{finance.staff_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
</div>
)}
</div>
</section>
)}
</div>
);
}