/** * How children do academically — tier-1 dot strips anchored on official * England averages, tier-2 "More measures" one tap away, equity row against * the computed state-school benchmark. Copy verbatim from the reviewed * mockups; teacher-assessed measures are labelled as such. */ 'use client'; import { latestValues, verdict } from '@/lib/compareLogic'; import { isSpecialSchool } from '@/lib/utils'; import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types'; import { DotStrip } from '@/components/DotStrip'; import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; import styles from './CompareAcademics.module.css'; interface StripSpec { label: string; metric: string; anchorKey?: string; tip?: string; min?: number; max?: number; unit?: string; } const TIER1_PRIMARY: StripSpec[] = [ { label: 'Reading, writing & maths — expected standard', metric: 'rwm_expected_pct', anchorKey: 'rwm_expected_pct', tip: '% of Year 6 pupils reaching the expected standard in reading, writing and maths.', }, { label: 'Reading', metric: 'reading_expected_pct', anchorKey: 'reading_expected_pct' }, { label: 'Writing (teacher-assessed)', metric: 'writing_expected_pct', anchorKey: 'writing_expected_pct', tip: 'Writing is assessed by teachers, not tested.', }, { label: 'Maths', metric: 'maths_expected_pct', anchorKey: 'maths_expected_pct' }, { label: 'Working at a higher standard than expected', metric: 'rwm_high_pct', anchorKey: 'rwm_high_pct', tip: 'A high score in the reading and maths tests plus “greater depth” in teacher-assessed writing.', }, ]; const TIER2_PRIMARY: StripSpec[] = [ { label: 'Grammar, punctuation & spelling — expected standard', metric: 'gps_expected_pct', anchorKey: 'gps_expected_pct', }, { label: 'Science — expected standard (teacher-assessed)', metric: 'science_expected_pct', anchorKey: 'science_expected_pct', tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.', }, { label: 'Average scaled score — reading', metric: 'reading_avg_score', anchorKey: 'reading_avg_score', min: 100, max: 120, unit: '', }, { label: 'Average scaled score — maths', metric: 'maths_avg_score', anchorKey: 'maths_avg_score', min: 100, max: 120, unit: '', }, { label: 'Average scaled score — grammar, punctuation & spelling', metric: 'gps_avg_score', anchorKey: 'gps_avg_score', min: 100, max: 120, unit: '', }, ]; function Strip({ spec, data, urns, schoolNames, national, special, }: { spec: StripSpec; data: Record; urns: number[]; schoolNames: string[]; national: Record | undefined; /** Per-school special-school flag; special schools' mainstream attainment is * not a fair comparison, so it's dropped from the strip (no dot). */ special: boolean[]; }) { const values = latestValues(data, urns, spec.metric).map((v, i) => v != null && !special[i] ? Math.round(v) : null, ); const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined; const anchor = anchorValue != null ? { value: anchorValue, label: `England ${Math.round(anchorValue)}${spec.unit ?? '%'}` } : null; if (values.every((v) => v == null)) return null; return ( ); } export function CompareAcademics({ schools, data, nationalAverages, benchmarks, isSecondary: propIsSecondary, }: { schools: School[]; data: Record; nationalAverages?: NationalAverages; benchmarks?: Benchmarks; isSecondary?: boolean; }) { const urns = schools.map((school) => school.urn); const schoolNames = schools.map((school) => school.school_name); // Special schools / PRUs / AP: their pupils sit the same assessments but very // few reach the mainstream standard, so their attainment isn't a fair // like-for-like comparison — drop it (progress banding, which IS meaningful, // is kept). const specialFlags = schools.map((school) => isSpecialSchool(school)); const dropSpecial = (vals: Array) => vals.map((v, i) => (specialFlags[i] ? null : v)); const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some( (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, ); if (isSecondary) { const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score')); const banding = urns.map((urn) => { const rows = data[String(urn)]?.yearly_data ?? []; for (let i = rows.length - 1; i >= 0; i--) { if (rows[i].progress_8_banding) return rows[i].progress_8_banding as string; } return null; }); // DfE stopped publishing Progress 8 from 2024/25: those GCSE year groups // sat no KS2 tests (COVID), so there is no baseline to measure progress // from. A bare "No data" reads as a gap on our side — say why. Judged // PER SCHOOL on its own latest data year: a school whose data simply // stops earlier (an unrelated gap) must not borrow the COVID explanation // from a neighbour that does have 2024/25 data. const p8NotPublished = urns.map((urn) => { const rows = data[String(urn)]?.yearly_data ?? []; const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0; return y >= 202425; }); const grade5 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct')); const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct')); const att8Anchor = nationalAverages?.secondary?.attainment_8_score; const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct; const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct; // Every headline number gets its England anchor + verdict chip, so the // "anchored against the England average" promise holds for the grade-5 // and EBacc rows too, not just Attainment 8. const anchorChip = (value: number | null, anchor: number | null | undefined, tol: number) => { if (value == null || anchor == null) return null; const v = verdict(value, anchor, tol); return ( {v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average ); }; return (
Attainment 8 {schools.map((school, i) => ( {att8[i] != null ? ( <> {(att8[i] as number).toFixed(1)}{' '} {anchorChip(att8[i], att8Anchor, 2)} {att8Anchor != null && ( England average {att8Anchor.toFixed(1)} )} ) : ( No data )} ))} Progress 8 {schools.map((school, i) => ( {banding[i] ? ( {banding[i]} ) : p8NotPublished[i] ? ( Not published — this GCSE year group sat no KS2 tests (COVID), so DfE has no baseline to measure progress from ) : ( No data )} ))} Grade 5+ in English & maths {schools.map((school, i) => ( {grade5[i] != null ? ( <> {Math.round(grade5[i] as number)}% {' '} {anchorChip(grade5[i], grade5Anchor, 3)} {grade5Anchor != null && ( England average {Math.round(grade5Anchor)}% )} ) : ( No data )} ))} EBacc entry {schools.map((school, i) => ( {ebacc[i] != null ? ( <> {Math.round(ebacc[i] as number)}% {' '} {anchorChip(ebacc[i], ebaccAnchor, 3)} {ebaccAnchor != null && ( England average {Math.round(ebaccAnchor)}% )} ) : ( No data )} ))}
); } const national = nationalAverages?.primary; const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct')); const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null; // Cohort size behind the disadvantaged figure (spec §8.5): these are small // groups where single pupils move the percentage — show roughly how many // pupils the figure rests on. Taken from the SAME yearly row that supplies // the displayed percentage: resolving eligible_pupils and the // disadvantaged share independently could mix years and misstate the // cohort behind the figure. const cohorts = urns.map((urn) => { const rows = data[String(urn)]?.yearly_data ?? []; for (let i = rows.length - 1; i >= 0; i--) { const row = rows[i]; if (row.rwm_expected_disadvantaged_pct != null) { if (row.eligible_pupils == null || row.disadvantaged_pct == null) return null; const cohort = Math.round((row.eligible_pupils * row.disadvantaged_pct) / 100); return cohort > 0 ? cohort : null; } } return null; }); return (
{TIER1_PRIMARY.map((spec) => ( ))}
More measures — grammar, punctuation & spelling, science, average scaled scores {TIER2_PRIMARY.map((spec) => ( ))}

The scaled-score strips show the 100–120 window of the full 80–120 range; 100 is the expected standard. Where an England tick is missing, the official figure isn't in our dataset yet.

{disadvantaged.some((v) => v != null) && ( Children from lower-income families {schools.map((school, i) => { const value = disadvantaged[i]; return ( {value != null ? ( <> {Math.round(value)}% {' '} {cohorts[i] != null && ( of ~{cohorts[i]} disadvantaged pupils )}{' '} {disadvantagedAnchor != null && ( {verdict(value, disadvantagedAnchor, 5) === 'above' && `Well above the ${Math.round(disadvantagedAnchor)}% state-school average`} {verdict(value, disadvantagedAnchor, 5) === 'close' && `Around the ${Math.round(disadvantagedAnchor)}% state-school average`} {verdict(value, disadvantagedAnchor, 5) === 'below' && `Below the ${Math.round(disadvantagedAnchor)}% state-school average`} )} ) : ( No data )} ); })} )}
); }