Files
school_compare/nextjs-app/components/SecondarySchoolDetailView.tsx
T
TudorandClaude Opus 4.8 ae6ef6860b
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m8s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m1s
fix: don't portray special schools as failing the mainstream benchmark
Special schools, PRUs and alternative provision teach pupils with SEND who
sit the same KS2/KS4 assessments but very few reach the mainstream "expected
standard". Their headline attainment is therefore ~0% (or a very low
Attainment 8), and the site was comparing that to the England average and
painting it red — e.g. Greenmead School (a community special school) rendered
as "0.0% — −62 pts below England average" with three 0% red SATs bars. That
portrays a special school as catastrophically failing against a benchmark
that doesn't fit it.

Add a shared `isSpecialSchool()` helper (detects every DfE special-school
establishment type — all contain "special" — plus PRUs / alternative
provision) and drop the mainstream England comparison + "below" framing for
these schools across every surface:

- Detail (primary + secondary): a plain-English context note explaining the
  school is special and why the comparison isn't shown; England-average delta
  chips, "England avg" hints, the SATs national markers, the Attainment-8
  "vs national" bar and the trend chart's England overlay are all suppressed.
  An all-zero placeholder SATs row hides the (empty) subject bar chart and the
  "why is combined lower" bridge.
- Rankings / search rows (primary + secondary): the mainstream RWM / Attainment
  8 stat shows "—" with no "vs national" delta, instead of "0% · −62 vs
  national".
- Compare: special schools' attainment values are dropped (no misleading dot
  at 0% / no "Below England average" chip); progress banding, which IS a fair
  measure for special schools, is kept.

Belt-and-braces zero-guard: a whole-row zero attainment (special or a
suppressed cohort) is also treated as not-comparable, while a legitimate
single 0 (e.g. 0% exceeding at a mainstream school) stays comparable.

Tests: new isSpecialSchool unit tests (every DfE special type matched, no
mainstream false positives); an e2e journey asserts Greenmead shows the
special-school note and no England-average comparison. tsc clean; 108/108 unit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 20:39:16 +01:00

935 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SecondarySchoolDetailView Component
* Dedicated detail view for secondary schools with scroll-to-section navigation.
* All sections render at once; the sticky nav scrolls to each.
*/
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { useComparison } from '@/hooks/useComparison';
import { MetricTooltip } from './MetricTooltip';
import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
const PerformanceChart = dynamic(
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
{ ssr: false },
);
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.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, modStyles: Record<string, string>): string {
if (val == null) return '';
if (val > 0) return modStyles.progressPositive;
if (val < 0) return modStyles.progressNegative;
return '';
}
function deprivationDesc(decile: number): string {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
}
interface SecondarySchoolDetailViewProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
senDetail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
export function SecondarySchoolDetailView({
schoolInfo, yearlyData,
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
}: SecondarySchoolDetailViewProps) {
const router = useRouter();
// Hero map — the "View on map" link opens its fullscreen view.
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
const [activeSection, setActiveSection] = useState<string>('');
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
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 secondaryAvg = nationalAvg?.secondary ?? {};
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
const p8Suspended = latestResults != null && latestResults.year >= 202425;
const hasResults = latestResults?.attainment_8_score != null;
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
// so their headline attainment is far below the mainstream average by design.
// Drop the England comparison + "below" framing so the page doesn't portray
// them as failing against a benchmark that doesn't fit. (Also guards a
// placeholder all-zero Attainment 8.)
const isSpecial = isSpecialSchool(schoolInfo);
const suppressComparison = isSpecial || (latestResults != null && latestResults.attainment_8_score === 0);
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
const denom = schoolInfo.religious_denomination ?? '';
if (denom && denom !== 'Does not apply') return 'Faith priority';
return null;
})();
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
} else {
addSchool(schoolInfo);
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
}
};
// Back returns wherever the user came from; deep-links fall back to search
// so the button never dead-ends or leaves the site.
const handleBack = () => {
if (typeof window !== 'undefined' && window.history.length > 1) {
router.back();
} else {
router.push('/search');
}
};
const scrollToTop = () => {
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
};
useEffect(() => {
track('school_viewed', {
urn: schoolInfo.urn,
phase: schoolInfo.phase || 'secondary',
local_authority: schoolInfo.local_authority || 'unknown',
from: getNavigationSource(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schoolInfo.urn]);
// Build nav items dynamically based on available data.
// Engagement-led order (matches the primary page): recognised Ofsted badge,
// then the most-sought sections — results, admissions, history — with the
// experience and context sections following.
const navItems: { id: string; label: string }[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
// Track active section as user scrolls
useEffect(() => {
const ids = navItems.map(n => n.id);
if (!ids.length) return;
const observers: IntersectionObserver[] = [];
const ratioMap: Record<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
);
// Report cards are dated by their own inspection (rc_inspection_date), never
// the legacy inspection_date (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
// National Attainment 8 baseline for the "Results Over Time" chart.
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
return (
<div className={styles.container}>
{/* Standalone back link, above the header — returns wherever the user
came from. Scrolls away; the sticky bar keeps a "back to top" control. */}
<button type="button" onClick={handleBack} className={styles.topBack}>
<span aria-hidden="true"></span> Back
</button>
{/* ── Header — the location map band blends 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.badges}>
{schoolInfo.school_type && (
<span className={styles.badge}>{schoolInfo.school_type}</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.badge}>{schoolInfo.gender}&apos;s school</span>
)}
{schoolInfo.age_range && (
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{hasSixthForm && (
<span className={styles.badge}>Sixth form</span>
)}
{admissionsTag && (
<span className={`${styles.badge} ${admissionsTag === 'Selective' ? styles.badgeSelective : styles.badgeFaith}`}>
{admissionsTag}
</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>
)}
{(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {(schoolInfo.total_pupils ?? latestResults!.total_pupils!).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}>
<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 ─────────────────────── */}
<nav className={styles.tabNav} aria-label="Page sections">
<div className={styles.tabNavInner}>
<button onClick={scrollToTop} className={styles.backBtn} aria-label="Back to top"> Top</button>
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
{navItems.map(({ id, label }) => (
<a
key={id}
href={`#${id}`}
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
onClick={() => track('section_nav_used', { section: id })}
>
{label}
</a>
))}
</div>
</nav>
{/* ── Ofsted ─────────────────────────────────────── */}
{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 ? (
<>
<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.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).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>
</>
) : ofsted.overall_effectiveness ? (
<>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{OFSTED_LABELS[ofsted.overall_effectiveness]}
</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.'}
</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>
)}
</>
) : (
<>
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={styles.metricsGrid}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
{ label: 'Personal Development', value: ofsted.personal_development },
{ label: 'Leadership & Management', value: ofsted.leadership_management },
].filter(({ value }) => value != null).map(({ label, value }) => (
<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>
)}
{/* ── GCSE Results ───────────────────────────────── */}
{hasResults && latestResults && (
<section id="gcse" className={styles.card}>
<h2 className={styles.sectionTitle}>
GCSE Results ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
</p>
{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 GCSEs, but their
headline attainment is far below the mainstream average by design, so a comparison
with the England average isn&rsquo;t a meaningful guide to the school.
</div>
)}
{p8Suspended && (
<div className={styles.p8Banner}>
Progress 8 isn&apos;t published for 2024/25: this GCSE year group sat no KS2 tests
(COVID), so DfE has no starting point to measure their progress from.
</div>
)}
{/* Hero stat cards — top GCSE metrics */}
<div className={styles.heroStatGrid}>
{latestResults.attainment_8_score != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Attainment 8 score
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.heroStatValue}>
{latestResults.attainment_8_score.toFixed(1)}
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
<DeltaChip
value={latestResults.attainment_8_score}
baseline={secondaryAvg.attainment_8_score}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
{latestResults.progress_8_score != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Progress 8 score
<MetricTooltip metricKey="progress_8_score" />
</div>
<div className={`${styles.heroStatValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
{formatProgress(latestResults.progress_8_score)}
</div>
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
<div className={styles.heroStatHint}>
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
</div>
) : (
<div className={styles.heroStatHint}>National baseline: 0.0</div>
)}
</div>
)}
{latestResults.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
English &amp; Maths Grade 5+
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_strong_pass_pct}
baseline={secondaryAvg.english_maths_strong_pass_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
English &amp; Maths Grade 4+
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_standard_pass_pct}
baseline={secondaryAvg.english_maths_standard_pass_pct}
unit="pts"
size="sm"
/>
)}
</div>
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* Attainment 8 visual bar (080 scale). This viz is explicitly
"school vs national", so it's dropped for special schools where
that comparison isn't meaningful. */}
{!suppressComparison && latestResults.attainment_8_score != null && (
<div className={styles.att8Viz}>
<div className={styles.att8VizLabel}>Attainment 8 school vs national</div>
<div className={styles.att8VizTrack}>
<div
className={styles.att8VizFill}
style={{ width: `${Math.min((latestResults.attainment_8_score / 80) * 100, 100)}%` }}
/>
{secondaryAvg.attainment_8_score != null && (
<div
className={styles.att8VizNatLine}
style={{ left: `${(secondaryAvg.attainment_8_score / 80) * 100}%` }}
>
<div className={styles.att8VizNatPill}>
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
</div>
</div>
)}
</div>
<div className={styles.att8VizTicks}>
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span>
</div>
</div>
)}
{/* Progress 8 number line with CI */}
{latestResults.progress_8_score != null && !p8Suspended && (
<div className={styles.p8Viz}>
<div className={styles.p8VizLabel}>Progress 8 relative to national baseline (0)</div>
{(() => {
const p8 = latestResults.progress_8_score!;
const lo = latestResults.progress_8_lower_ci ?? p8;
const hi = latestResults.progress_8_upper_ci ?? p8;
const range = 6; // 3 to +3
const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`;
return (
<div className={styles.p8VizTrack}>
{/* CI band */}
<div
className={styles.p8VizCi}
style={{ left: toX(lo), width: `calc(${toX(hi)} - ${toX(lo)})` }}
/>
{/* Zero line */}
<div className={styles.p8VizZero} style={{ left: toX(0) }} />
{/* Score dot */}
<div
className={`${styles.p8VizDot} ${p8 < 0 ? styles.p8VizDotNeg : ''}`}
style={{ left: toX(p8) }}
/>
</div>
);
})()}
<div className={styles.p8VizTicks}>
<span>3</span><span>2</span><span>1</span><span>0</span><span>+1</span><span>+2</span><span>+3</span>
</div>
</div>
)}
{/* Progress 8 component breakdown */}
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
<>
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
<div className={styles.metricTable}>
{[
{ label: 'English', val: latestResults.progress_8_english },
{ label: 'Maths', val: latestResults.progress_8_maths },
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
].filter(r => r.val != null).map(({ label, val }) => (
<div key={label} className={styles.metricRow}>
<span className={styles.metricName}>{label}</span>
<span className={`${styles.metricValue} ${progressClass(val, styles)}`}>
{formatProgress(val!)}
</span>
</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+</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+</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_avg_score != null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>EBacc average point score</span>
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
</div>
)}
</div>
</>
)}
</section>
)}
{/* ── Admissions ─────────────────────────────────── */}
{admissions && (
<section id="admissions" className={styles.card}>
<h2 className={styles.sectionTitle}>Admissions</h2>
{admissionsTag && (
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
<strong>{admissionsTag}</strong>{' '}
{admissionsTag === 'Selective'
? '— Entry to this school is by selective examination (e.g. 11+).'
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
</div>
)}
<div className={styles.metricsGrid}>
{admissions.places_offered != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 7 places offered</div>
<div className={styles.metricValue}>{admissions.places_offered}</div>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total applications</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>1st preference applications</div>
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offer_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first choice</div>
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
</div>
)}
</div>
{admissions.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ Applications exceeded places last year'
: '✓ Places were available last year'}
</div>
)}
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
</p>
{hasSixthForm && (
<div className={styles.sixthFormNote}>
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
</div>
)}
</section>
)}
{/* ── History table ──────────────────────────────── */}
{yearlyData.length > 1 && (
<section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Historical Results</h2>
{yearlyData.length > 0 && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
/>
</div>
</>
)}
<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>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>Eng &amp; Maths 4+</th>
<th>EBacc entry %</th>
</tr>
</thead>
<tbody>
{yearlyData.map((result) => (
<tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</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>
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</section>
)}
{/* ── Wellbeing ──────────────────────────────────── */}
{hasWellbeing && (
<section id="wellbeing" className={styles.card}>
<h2 className={styles.sectionTitle}>Wellbeing &amp; Context</h2>
{/* SEN */}
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
<>
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
<div className={styles.heroStatGrid}>
{latestResults?.sen_support_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
<div className={styles.heroStatHint}>Without an EHCP</div>
</div>
)}
{latestResults?.sen_ehcp_pct != null && (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>
Pupils with EHCP
<MetricTooltip metricKey="sen_ehcp_pct" />
</div>
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
</div>
)}
{(() => {
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
if (total == null) return null;
const female = census?.female_pupils ?? null;
const male = census?.male_pupils ?? null;
const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
const hasSplit = isMixed && female != null && male != null && female + male > 0;
const sum = hasSplit ? female! + male! : 0;
const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0;
const boysPct = hasSplit ? 100 - girlsPct : 0;
return (
<div className={styles.heroStatCard}>
<div className={styles.heroStatLabel}>Total pupils</div>
<div className={styles.heroStatValue}>{total.toLocaleString()}</div>
{hasSplit && (
<>
<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.genderSplitHint}>
<span className={styles.genderSplitGirls}>{girlsPct}% girls</span>
<span className={styles.genderSplitSep}> · </span>
<span className={styles.genderSplitBoys}>{boysPct}% boys</span>
</div>
</>
)}
{schoolInfo.capacity != null && !hasSplit && (
<div className={styles.heroStatHint}>Capacity: {schoolInfo.capacity}</div>
)}
</div>
);
})()}
</div>
</>
)}
{/* Deprivation */}
{hasDeprivation && deprivation && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
Local Area Context
<MetricTooltip metricKey="idaci_decile" />
</h3>
<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>
)}
{finance.premises_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on premises</div>
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
</div>
)}
</div>
</section>
)}
</div>
);
}