fix: don't portray special schools as failing the mainstream benchmark
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
This commit is contained in:
Tudor
2026-07-20 20:39:16 +01:00
co-authored by Claude Opus 4.8
parent 5e370d09f1
commit ae6ef6860b
10 changed files with 276 additions and 71 deletions
@@ -8,6 +8,7 @@
'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';
@@ -90,15 +91,19 @@ function Strip({
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) =>
v != null ? Math.round(v) : null,
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 =
@@ -135,12 +140,19 @@ export function CompareAcademics({
}) {
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 = latestValues(data, urns, 'attainment_8_score');
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--) {
@@ -159,8 +171,8 @@ export function CompareAcademics({
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return y >= 202425;
});
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
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;
@@ -272,7 +284,7 @@ export function CompareAcademics({
}
const national = nationalAverages?.primary;
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
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
@@ -307,6 +319,7 @@ export function CompareAcademics({
urns={urns}
schoolNames={schoolNames}
national={national}
special={specialFlags}
/>
))}
@@ -320,6 +333,7 @@ export function CompareAcademics({
urns={urns}
schoolNames={schoolNames}
national={national}
special={specialFlags}
/>
))}
<p className={styles.stripNote}>