Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The report link hardcoded provider type 21, which 404s for every school whose Ofsted provider type isn't 21 (nurseries -> 20, academies -> 23, etc.). Use Ofsted's URN-based find-inspection-report URL, which redirects to the correct provider page for any school, and rename the link to "Ofsted reports" since it points to the school's Ofsted page (all reports), not one report. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
942 lines
44 KiB
TypeScript
942 lines
44 KiB
TypeScript
/**
|
||
* 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, OfstedParentView, SchoolCensus,
|
||
SchoolAdmissions, SenDetail, Phonics,
|
||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||
} from '@/lib/types';
|
||
import { formatPercentage, formatProgress, formatAcademicYear } 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;
|
||
parentView: OfstedParentView | 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, parentView, 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 ?? {};
|
||
|
||
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
|
||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||
const hasParents = parentView != null && parentView.total_responses != null && parentView.total_responses > 0;
|
||
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;
|
||
|
||
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 (hasParents) navItems.push({ id: 'parents', label: 'Parents' });
|
||
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(',')]);
|
||
|
||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||
const oeifAllSameGrade = (() => {
|
||
if (!ofsted || ofsted.framework === 'ReportCard') return false;
|
||
const subs = [
|
||
ofsted.quality_of_education,
|
||
ofsted.behaviour_attitudes,
|
||
ofsted.personal_development,
|
||
ofsted.leadership_management,
|
||
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
|
||
].filter((v): v is number => v != null);
|
||
return subs.length >= 3 && subs.every(v => v === 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}'s school</span>
|
||
)}
|
||
{schoolInfo.age_range && (
|
||
<span className={styles.badge}>{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>
|
||
{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}
|
||
>
|
||
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
||
</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}>
|
||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||
{ofsted.inspection_date && (
|
||
<span className={styles.ofstedDate}>
|
||
{' '}Inspected {new Date(ofsted.inspection_date).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>
|
||
{ofsted.framework === 'ReportCard' ? (
|
||
<>
|
||
<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}>
|
||
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' Development and Leadership.
|
||
</p>
|
||
) : (
|
||
<div className={styles.metricsGrid}>
|
||
{[
|
||
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
||
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
|
||
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
|
||
{ label: 'School Leadership', value: ofsted.leadership_management },
|
||
...(ofsted.early_years_provision != null
|
||
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
|
||
: []),
|
||
].map(({ label, value }) => value != null && (
|
||
<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>
|
||
</>
|
||
)}
|
||
{hasParents && (
|
||
<p className={styles.parentRecommendLine}>
|
||
<strong>{Math.round(parentView!.q_recommend_pct!)}%</strong> of parents would recommend this school ({parentView!.total_responses!.toLocaleString()} responses)
|
||
</p>
|
||
)}
|
||
</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. National averages shown for comparison.
|
||
</p>
|
||
|
||
{p8Suspended && (
|
||
<div className={styles.p8Banner}>
|
||
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
|
||
</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)}
|
||
{secondaryAvg.attainment_8_score != null && (
|
||
<DeltaChip
|
||
value={latestResults.attainment_8_score}
|
||
baseline={secondaryAvg.attainment_8_score}
|
||
unit="pts"
|
||
size="sm"
|
||
/>
|
||
)}
|
||
</div>
|
||
{secondaryAvg.attainment_8_score != null && (
|
||
<div className={styles.heroStatHint}>National 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 & Maths Grade 5+
|
||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||
</div>
|
||
<div className={styles.heroStatValue}>
|
||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||
{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>
|
||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||
<div className={styles.heroStatHint}>National 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 & Maths Grade 4+
|
||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||
</div>
|
||
<div className={styles.heroStatValue}>
|
||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||
{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>
|
||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Attainment 8 visual bar (0–80 scale) */}
|
||
{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={heroAtt8Nat}
|
||
nationalByYear={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 & 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>
|
||
)}
|
||
{/* ── Parent View ────────────────────────────────── */}
|
||
{hasParents && parentView && (
|
||
<section id="parents" className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>
|
||
What Parents Say
|
||
<span className={styles.responseBadge}>
|
||
{parentView.total_responses!.toLocaleString()} responses
|
||
</span>
|
||
</h2>
|
||
<p className={styles.sectionSubtitle}>
|
||
From the Ofsted Parent View survey — parents share their experience of this school.
|
||
</p>
|
||
<div className={styles.parentViewGrid}>
|
||
{[
|
||
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
|
||
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
|
||
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
|
||
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
|
||
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
|
||
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
|
||
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
|
||
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
|
||
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
|
||
].filter(q => q.pct != null).map(({ label, pct }) => (
|
||
<div key={label} className={styles.parentViewRow}>
|
||
<span className={styles.parentViewLabel}>{label}</span>
|
||
<div className={styles.parentViewBar}>
|
||
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* ── Wellbeing ──────────────────────────────────── */}
|
||
{hasWellbeing && (
|
||
<section id="wellbeing" className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>Wellbeing & 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'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>
|
||
);
|
||
}
|