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
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
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
/**
|
||
* 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 & 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 0–100%; 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 & 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 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.
|
||
</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>
|
||
);
|
||
}
|