Files
school_compare/nextjs-app/components/compare/CompareAcademics.tsx
T
TudorandClaude Fable 5 f3fa12806b
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
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 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m8s
fix(compare): expert should-fixes S1-S4, S6 — banded chips, P8 reason, KS4 gap caption, all-through framing, cohort sizes
S1: first-choice chip banded (More than half / About 1 in 3 / Over 1 in 4
missed out) so a 44%-offered grammar isn't understated by half.
S2: Progress 8 explains its absence for 2024/25+ cohorts (no KS2 baseline,
COVID) instead of a bare 'No data'.
S3: KS4 trend charts get their own honest gap caption (2019/20-2020/21
unpublished; later years not in our dataset yet); y-axis 'Value'→'Score';
buildCompareChart exposes englandOnlyYears.
S4: all-through schools labelled in chips, rail caption says 'N schools ·
<phase> view' for mixed baskets, whole-school roll no longer judged
against the single-phase median, community section carries an all-ages
caveat.
S6 (spec §8.5): disadvantaged attainment shows the cohort behind it
('of ~50 disadvantaged pupils').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 17:41:12 +01:00

324 lines
12 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.
/**
* 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 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,
}: {
spec: StripSpec;
data: Record<string, ComparisonData>;
urns: number[];
schoolNames: string[];
national: Record<string, number> | undefined;
}) {
const values = latestValues(data, urns, spec.metric).map((v) =>
v != null ? 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 (
<DotStrip
label={spec.label}
values={values}
schoolNames={schoolNames}
anchor={anchor}
min={spec.min ?? 0}
max={spec.max ?? 100}
unit={spec.unit ?? '%'}
tip={spec.tip}
/>
);
}
export function CompareAcademics({
schools,
data,
nationalAverages,
benchmarks,
isSecondary: propIsSecondary,
}: {
schools: School[];
data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages;
benchmarks?: Benchmarks;
isSecondary?: boolean;
}) {
const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name);
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
if (isSecondary) {
const att8 = 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.
const latestYear = urns.reduce((max, urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return Math.max(max, y);
}, 0);
const p8NotPublished = latestYear >= 202425;
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
return (
<Section
title="How students do academically"
how="GCSE results (latest year). Attainment 8 averages performance across eight subjects; Progress 8 shows how much progress students make compared with similar students nationally — the wording is DfE's own banding."
>
<SectionGrid schools={schools}>
<RowLabel tip="Average Attainment 8 score across eight GCSE subjects.">Attainment 8</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{att8[i] != null ? (
<>
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>
{att8Anchor != null && (
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
))}
<RowLabel tip="DfE's own plain-English Progress 8 label.">Progress 8</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{banding[i] ? (
<Chip
tone={
/well above|above/i.test(banding[i] as string)
? 'good'
: /well below|below/i.test(banding[i] as string)
? 'warn'
: 'neutral'
}
>
{banding[i]}
</Chip>
) : p8NotPublished ? (
<span className={s.small}>
Not published this GCSE year group sat no KS2 tests (COVID), so DfE has no
baseline to measure progress from
</span>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
))}
<RowLabel tip="% achieving grade 5 or above in both English and maths GCSEs.">
Grade 5+ in English &amp; maths
</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>}
</Cell>
))}
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>}
</Cell>
))}
</SectionGrid>
</Section>
);
}
const national = nationalAverages?.primary;
const disadvantaged = 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.
const eligible = latestValues(data, urns, 'eligible_pupils');
const disadvantagedShare = latestValues(data, urns, 'disadvantaged_pct');
const cohorts = urns.map((_, i) => {
const n = eligible[i];
const share = disadvantagedShare[i];
if (n == null || share == null) return null;
const cohort = Math.round((n * share) / 100);
return cohort > 0 ? cohort : null;
});
return (
<Section
title="How children do academically"
how="Results from national tests and teacher assessments at the end of Year 6 — writing is assessed by teachers, not tested. Each line runs from 0100%; the grey tick marks the England average, so dots to its right are above average."
>
<div className={s.card}>
{TIER1_PRIMARY.map((spec) => (
<Strip
key={spec.metric}
spec={spec}
data={data}
urns={urns}
schoolNames={schoolNames}
national={national}
/>
))}
<details className={styles.moreMeasures}>
<summary>More measures grammar, punctuation &amp; spelling, science, average scaled scores</summary>
{TIER2_PRIMARY.map((spec) => (
<Strip
key={spec.metric}
spec={spec}
data={data}
urns={urns}
schoolNames={schoolNames}
national={national}
/>
))}
<p className={styles.stripNote}>
The scaled-score strips show the 100120 window of the full 80120 range; 100 is the
expected standard. Where an England tick is missing, the official figure isn&apos;t in
our dataset yet.
</p>
</details>
</div>
{disadvantaged.some((v) => v != null) && (
<SectionGrid schools={schools}>
<RowLabel tip="% of disadvantaged pupils (free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. Benchmark computed across state schools in our dataset. Based on smaller pupil groups, so a single pupil can move a school's figure noticeably.">
Children from lower-income families
</RowLabel>
{schools.map((school, i) => {
const value = disadvantaged[i];
return (
<Cell key={school.urn} school={school} index={i}>
{value != null ? (
<>
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(value)}%
</span>{' '}
{cohorts[i] != null && (
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
)}{' '}
{disadvantagedAnchor != null && (
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
{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`}
</Chip>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</SectionGrid>
)}
</Section>
);
}