PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m8s
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 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m1s
Special schools, PRUs and alternative provision teach pupils with SEND who sit the same KS2/KS4 assessments but very few reach the mainstream "expected standard". Their headline attainment is therefore ~0% (or a very low Attainment 8), and the site was comparing that to the England average and painting it red — e.g. Greenmead School (a community special school) rendered as "0.0% — −62 pts below England average" with three 0% red SATs bars. That portrays a special school as catastrophically failing against a benchmark that doesn't fit it. Add a shared `isSpecialSchool()` helper (detects every DfE special-school establishment type — all contain "special" — plus PRUs / alternative provision) and drop the mainstream England comparison + "below" framing for these schools across every surface: - Detail (primary + secondary): a plain-English context note explaining the school is special and why the comparison isn't shown; England-average delta chips, "England avg" hints, the SATs national markers, the Attainment-8 "vs national" bar and the trend chart's England overlay are all suppressed. An all-zero placeholder SATs row hides the (empty) subject bar chart and the "why is combined lower" bridge. - Rankings / search rows (primary + secondary): the mainstream RWM / Attainment 8 stat shows "—" with no "vs national" delta, instead of "0% · −62 vs national". - Compare: special schools' attainment values are dropped (no misleading dot at 0% / no "Below England average" chip); progress banding, which IS a fair measure for special schools, is kept. Belt-and-braces zero-guard: a whole-row zero attainment (special or a suppressed cohort) is also treated as not-comparable, while a legitimate single 0 (e.g. 0% exceeding at a mainstream school) stays comparable. Tests: new isSpecialSchool unit tests (every DfE special type matched, no mainstream false positives); an e2e journey asserts Greenmead shows the special-school note and no England-average comparison. tsc clean; 108/108 unit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
386 lines
15 KiB
TypeScript
386 lines
15 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 { 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<string, ComparisonData>;
|
||
urns: number[];
|
||
schoolNames: string[];
|
||
national: Record<string, number> | 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 (
|
||
<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);
|
||
// 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<number | null>) =>
|
||
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 (
|
||
<Chip tone={v === 'above' ? 'good' : v === 'below' ? 'warn' : 'neutral'}>
|
||
{v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average
|
||
</Chip>
|
||
);
|
||
};
|
||
|
||
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>{' '}
|
||
{anchorChip(att8[i], att8Anchor, 2)}
|
||
{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[i] ? (
|
||
<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 ? (
|
||
<>
|
||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||
{Math.round(grade5[i] as number)}%
|
||
</span>{' '}
|
||
{anchorChip(grade5[i], grade5Anchor, 3)}
|
||
{grade5Anchor != null && (
|
||
<span className={s.small}>England average {Math.round(grade5Anchor)}%</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<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 ? (
|
||
<>
|
||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||
{Math.round(ebacc[i] as number)}%
|
||
</span>{' '}
|
||
{anchorChip(ebacc[i], ebaccAnchor, 3)}
|
||
{ebaccAnchor != null && (
|
||
<span className={s.small}>England average {Math.round(ebaccAnchor)}%</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className={s.small}>No data</span>
|
||
)}
|
||
</Cell>
|
||
))}
|
||
</SectionGrid>
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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}
|
||
special={specialFlags}
|
||
/>
|
||
))}
|
||
|
||
<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}
|
||
special={specialFlags}
|
||
/>
|
||
))}
|
||
<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>
|
||
);
|
||
}
|