7e182e88b2
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 57s
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
Add lib/analytics.ts with a single typed track() wrapper. SSR-safe,
never throws, no-ops when Umami isn't loaded. Event names form a
fixed union so refactors stay safe.
14 events wired:
Discovery (3)
search_submitted FilterBar submit + near_me path
near_me_used all geolocation outcomes
empty_results search returns 0 schools
Engagement (5)
school_viewed SchoolDetail + Secondary on mount, with
urn / phase / local_authority / from
section_nav_used section-nav links on both detail views
chart_metric_changed mobile chart chip switch
metric_compared_in_rankings rankings metric dropdown
external_link_clicked Ofsted / school website / DfE (declarative
data-umami-event attributes)
Conversion (5)
compare_school_added search/rankings/detail/compare sources
compare_school_removed detail toggle and compare page
compare_viewed once per session when there's a selection
(school_count, phase_mix)
compare_metric_changed compare page metric dropdown
compare_shared native sheet vs clipboard distinguished
Operational (1)
api_error caught in handleResponse, includes
endpoint / status / route
Suggested Goals to configure in the Umami dashboard for the funnel
report: search_submitted → school_viewed → compare_school_added →
compare_viewed → compare_shared.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
984 lines
46 KiB
TypeScript
984 lines
46 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, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useComparison } from '@/hooks/useComparison';
|
||
import { PerformanceChart } from './PerformanceChart';
|
||
import { MetricTooltip } from './MetricTooltip';
|
||
import { SchoolMap } from './SchoolMap';
|
||
import type {
|
||
School, SchoolResult, AbsenceData,
|
||
OfstedInspection, OfstedParentView, SchoolCensus,
|
||
SchoolAdmissions, SenDetail, Phonics,
|
||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||
} from '@/lib/types';
|
||
import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } 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();
|
||
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' });
|
||
}
|
||
};
|
||
|
||
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
|
||
const navItems: { id: string; label: string }[] = [];
|
||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||
if (hasParents) navItems.push({ id: 'parents', label: 'Parents' });
|
||
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||
if (hasLocation) navItems.push({ id: 'location', label: 'Location' });
|
||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
||
|
||
// 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);
|
||
})();
|
||
|
||
// ── Hero signal chip & stats ─────────────────────────────────────────
|
||
const ofstedHeroChip = buildOfstedHeroChip(ofsted);
|
||
const heroAtt8 = latestResults?.attainment_8_score ?? null;
|
||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||
const heroAcademicYear = latestResults ? formatAcademicYear(latestResults.year) : '';
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
{/* ── Header ─────────────────────────────────────── */}
|
||
<header className={styles.header}>
|
||
<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}`}
|
||
</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>
|
||
|
||
{/* Hero signal chips */}
|
||
<div className={styles.heroChips}>
|
||
<div className={`${styles.heroChip} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
|
||
<div className={styles.heroChipTitle}>{ofstedHeroChip.title}</div>
|
||
<div className={styles.heroChipSub}>{ofstedHeroChip.subtitle}</div>
|
||
{ofstedHeroChip.detail && (
|
||
<div className={styles.heroChipDetail}>{ofstedHeroChip.detail}</div>
|
||
)}
|
||
</div>
|
||
|
||
{admissions?.oversubscribed && (
|
||
<div className={`${styles.heroChip} ${styles['tone-coral']}`}>
|
||
<div className={styles.heroChipTitle}>Oversubscribed</div>
|
||
<div className={styles.heroChipSub}>
|
||
{admissions.first_preference_offer_pct != null
|
||
? `${Math.round(admissions.first_preference_offer_pct)}% of first-choice applicants offered a place`
|
||
: 'More applicants than places'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* At-a-glance stats row */}
|
||
{latestResults && (
|
||
<div className={styles.heroStats}>
|
||
{heroAtt8 != null && (
|
||
<div className={styles.heroStat}>
|
||
<div className={styles.heroStatNumber}>{heroAtt8.toFixed(1)}</div>
|
||
<div className={styles.heroStatLabel}>Attainment 8 score</div>
|
||
{heroAtt8Nat != null && (
|
||
<DeltaChip value={heroAtt8} baseline={heroAtt8Nat} unit="pts" suffix="vs national" />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{ofsted && (
|
||
<div className={styles.heroStat}>
|
||
<div className={`${styles.heroStatNumberSerif} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
|
||
{ofstedHeroChip.state === 'oeif'
|
||
? ofstedHeroChip.title.replace(/^Ofsted\s+/, '')
|
||
: ofstedHeroChip.state === 'reportCard'
|
||
? 'Report Card'
|
||
: '—'}
|
||
</div>
|
||
<div className={styles.heroStatLabel}>{ofstedHeroChip.subtitle}</div>
|
||
{ofstedHeroChip.detail && (
|
||
<div className={styles.heroStatFoot}>{ofstedHeroChip.detail}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{admissions?.first_preference_offer_pct != null && (
|
||
<div className={styles.heroStat}>
|
||
<div className={styles.heroStatNumber}>
|
||
{Math.round(admissions.first_preference_offer_pct)}%
|
||
</div>
|
||
<div className={styles.heroStatLabel}>First-choice offer rate</div>
|
||
{admissions.oversubscribed && (
|
||
<div className={styles.heroStatFoot}>Oversubscribed</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{heroAcademicYear && (
|
||
<p className={styles.heroDataNote}>Latest data: {heroAcademicYear}</p>
|
||
)}
|
||
</header>
|
||
|
||
{/* ── Sticky section navigation ─────────────────────── */}
|
||
<nav className={styles.tabNav} aria-label="Page sections">
|
||
<div className={styles.tabNavInner}>
|
||
<button onClick={() => router.back()} className={styles.backBtn}>← Back</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/provider/21/${schoolInfo.urn}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={styles.ofstedReportLink}
|
||
data-umami-event="external_link_clicked"
|
||
data-umami-event-target="ofsted"
|
||
>
|
||
Full report ↗
|
||
</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>
|
||
)}
|
||
|
||
{/* ── 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>
|
||
)}
|
||
|
||
{/* ── 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>
|
||
)}
|
||
|
||
{/* ── 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>
|
||
)}
|
||
|
||
{/* ── Location ───────────────────────────────────── */}
|
||
{hasLocation && (
|
||
<section id="location" className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>Location</h2>
|
||
<div className={styles.mapContainer}>
|
||
<SchoolMap
|
||
schools={[schoolInfo]}
|
||
center={[schoolInfo.latitude!, schoolInfo.longitude!]}
|
||
zoom={15}
|
||
/>
|
||
</div>
|
||
</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>
|
||
)}
|
||
|
||
{/* ── 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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|