From ae6ef6860b061b675ea191c25c6c4895d8a4a828 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 20 Jul 2026 20:39:16 +0100 Subject: [PATCH 1/3] fix: don't portray special schools as failing the mainstream benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- e2e/tests/journeys.spec.ts | 26 +++- nextjs-app/__tests__/lib/utils.test.ts | 38 +++++ .../components/SchoolDetailView.module.css | 18 +++ nextjs-app/components/SchoolDetailView.tsx | 130 ++++++++++++------ nextjs-app/components/SchoolRow.tsx | 19 ++- .../SecondarySchoolDetailView.module.css | 16 +++ .../components/SecondarySchoolDetailView.tsx | 43 ++++-- nextjs-app/components/SecondarySchoolRow.tsx | 10 +- .../components/compare/CompareAcademics.tsx | 26 +++- nextjs-app/lib/utils.ts | 21 +++ 10 files changed, 276 insertions(+), 71 deletions(-) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 4df77d7..ab4ce8f 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -175,8 +175,30 @@ test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not ju // The combined results section carries both phases. const results = page.locator('#results'); await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible(); - await expect(results.getByText('Reading, Writing & Maths combined')).toBeVisible(); // KS2 - await expect(results.getByText('Attainment 8').first()).toBeVisible(); // KS4 + await expect(results.getByRole('heading', { name: /Primary.*KS2 SATs/ })).toBeVisible(); // KS2 block + await expect(results.getByRole('heading', { name: /Secondary.*GCSEs/ })).toBeVisible(); // KS4 block +}); + +test('a special school is not shown as failing against the mainstream England average', async ({ page }) => { + // Special schools sit the same tests but very few pupils reach the mainstream + // "expected standard", so a "0.0% · −62 pts below England average" rendering + // portrays them as failing against a benchmark that doesn't fit. The results + // section drops the England comparison and explains the context instead. + const SP_URN = 101099; // Greenmead School — a community special school + const res = await page.request.get(`/api/schools/${SP_URN}`); + expect(res.ok()).toBeTruthy(); + const detail = await res.json(); + test.skip( + !/special|pupil referral|alternative provision/i.test(detail.school_info?.school_type ?? ''), + 'precondition: chosen URN must currently be a special school', + ); + + await page.goto(`/school/${SP_URN}`); + const results = page.locator('#results'); + // The special-school context note is shown… + await expect(results.getByText(/This is a special school/i)).toBeVisible({ timeout: 15_000 }); + // …and the mainstream England-average comparison is dropped entirely. + await expect(results.getByText(/England avg/i)).toHaveCount(0); }); test('school with no performance data still gets a working detail page', async ({ page }) => { diff --git a/nextjs-app/__tests__/lib/utils.test.ts b/nextjs-app/__tests__/lib/utils.test.ts index f657d1b..38e3b78 100644 --- a/nextjs-app/__tests__/lib/utils.test.ts +++ b/nextjs-app/__tests__/lib/utils.test.ts @@ -269,6 +269,44 @@ describe('isProposedToClose', () => { }); }); +describe('isSpecialSchool', () => { + const { isSpecialSchool } = require('@/lib/utils'); + + it('matches every DfE special-school establishment type', () => { + for (const t of [ + 'Community special school', + 'Foundation special school', + 'Academy special converter', + 'Academy special sponsor led', + 'Non-maintained special school', + 'Free schools special', + 'Other independent special school', + ]) { + expect(isSpecialSchool({ school_type: t })).toBe(true); + } + }); + + it('matches pupil referral units and alternative provision', () => { + expect(isSpecialSchool({ school_type: 'Pupil referral unit' })).toBe(true); + expect(isSpecialSchool({ school_type: 'Academy alternative provision converter' })).toBe(true); + }); + + it('does not match mainstream types (no false positives)', () => { + for (const t of [ + 'Academy converter', + 'Community school', + 'Voluntary aided school', + 'Free schools', + 'University technical college', + 'Other independent school', + null, + undefined, + ]) { + expect(isSpecialSchool({ school_type: t })).toBe(false); + } + }); +}); + describe('shortName', () => { it('drops the trailing establishment-type words', () => { expect(shortName('Barclay Primary School')).toBe('Barclay'); diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index b2b419a..cfe453b 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -1558,3 +1558,21 @@ .closingStrip strong { color: #8a6200; } + +/* Special-school context note: soft, informational (teal), not a warning. It + explains why the mainstream attainment measures and England comparison are + dropped for special schools / PRUs / AP. */ +.specialNote { + background: var(--bg-secondary, #f3ede4); + border-left: 4px solid var(--accent-teal, #2d7d7d); + border-radius: 0 6px 6px 0; + padding: 0.7rem 1rem; + margin: 0 0 1rem; + font-size: 0.9rem; + line-height: 1.5; + color: var(--text-secondary, #5c564d); + max-width: 72ch; +} +.specialNote strong { + color: var(--text-primary, #1a1612); +} diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index e353795..be5f501 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -18,7 +18,7 @@ import type { SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { - formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, + formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool, } from '@/lib/utils'; import { DeltaChip } from './DeltaChip'; import { summariseAdmissions } from '@/lib/compareLogic'; @@ -233,6 +233,24 @@ export function SchoolDetailView({ const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null; const hasAnyResults = hasKS2Results || hasKS4Results; + // Special schools / PRUs / AP: their pupils sit the same tests but very few + // reach the mainstream "expected standard", so a 0% headline and an England + // comparison portray them as failing against a benchmark that doesn't fit. + const isSpecial = isSpecialSchool(schoolInfo); + // Belt-and-braces: a whole-row zero attainment (special or suppressed cohort) + // is a placeholder, not a real mainstream result — suppress its comparison + // too. A single 0 (e.g. 0% exceeding at a normal school) stays comparable. + const ks2Placeholder = latestResults != null + && latestResults.rwm_expected_pct === 0 + && (latestResults.reading_expected_pct ?? 0) === 0 + && (latestResults.writing_expected_pct ?? 0) === 0 + && (latestResults.maths_expected_pct ?? 0) === 0; + const ks4Placeholder = latestResults != null && latestResults.attainment_8_score === 0; + // Whether to drop the England-average deltas / national markers / "below" + // framing on the attainment measures. + const suppressKs2Comparison = isSpecial || ks2Placeholder; + const suppressKs4Comparison = isSpecial || ks4Placeholder; + // Build section nav items dynamically — only sections with data. // Order is engagement-led (from section_nav_used analytics): the most-sought // sections — results, admissions, inclusion, history — sit near the top, @@ -624,13 +642,32 @@ export function SchoolDetailView({ {isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})

- {isAllThrough - ? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.' - : isSecondary - ? 'GCSE results for Year 11 pupils. England averages shown for comparison.' - : 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'} + {isSpecial + ? (isSecondary + ? 'GCSE results for Year 11 pupils.' + : 'End-of-primary-school tests taken by Year 6 pupils.') + : isAllThrough + ? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.' + : isSecondary + ? 'GCSE results for Year 11 pupils. England averages shown for comparison.' + : 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}

+ {/* Special schools sit the same national assessments but teach pupils + with SEND, so very few reach the mainstream "expected standard". + Explain that up front and drop the England comparison below, so a + 0% headline never reads as a failing grade against a benchmark + that doesn't fit the school. */} + {isSpecial && ( +
+ This is a special school. Its pupils have special educational + needs and work towards individual targets. They sit the same national tests, but + very few reach the mainstream “expected standard” these measures report, + so a comparison with the England average isn’t a meaningful guide to the + school. Where available, the progress pupils make (below) is a fairer measure. +
+ )} + {/* ── Primary / KS2 content ── */} {hasKS2Results && ( <> @@ -646,7 +683,7 @@ export function SchoolDetailView({
{formatPercentage(latestResults.rwm_expected_pct)} - {primaryAvg.rwm_expected_pct != null && ( + {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && ( )}
- {primaryAvg.rwm_expected_pct != null && ( + {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%
)} @@ -668,7 +705,7 @@ export function SchoolDetailView({
{formatPercentage(latestResults.rwm_high_pct)} - {primaryAvg.rwm_high_pct != null && ( + {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && ( )}
- {primaryAvg.rwm_high_pct != null && ( + {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%
)} )} - {latestResults.rwm_expected_pct != null && + {!suppressKs2Comparison && + latestResults.rwm_expected_pct != null && latestResults.reading_expected_pct != null && latestResults.writing_expected_pct != null && latestResults.maths_expected_pct != null && ( @@ -708,28 +746,34 @@ export function SchoolDetailView({ )} - + {/* All-zero placeholder rows (special / suppressed) would render as + three empty bars against the national markers — misleading, so + skip the chart. For a special school with some non-zero + subjects, keep the bars but drop the national markers. */} + {!ks2Placeholder && ( + + )} {/* Progress scores row */} {(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && ( @@ -786,7 +830,7 @@ export function SchoolDetailView({
{latestResults.attainment_8_score.toFixed(1)}
- {secondaryAvg.attainment_8_score != null && ( + {!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
England avg: {secondaryAvg.attainment_8_score.toFixed(1)}
)} @@ -810,7 +854,7 @@ export function SchoolDetailView({
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
- {secondaryAvg.english_maths_standard_pass_pct != null && ( + {!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
)} @@ -822,7 +866,7 @@ export function SchoolDetailView({
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
- {secondaryAvg.english_maths_strong_pass_pct != null && ( + {!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
)} @@ -1076,8 +1120,8 @@ export function SchoolDetailView({ data={yearlyData} schoolName={schoolInfo.school_name} isSecondary={false} - nationalRwmAvg={primaryAvg.rwm_expected_pct ?? null} - nationalByYear={nationalAvg?.by_year} + nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)} + nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year} /> @@ -1090,8 +1134,8 @@ export function SchoolDetailView({ data={yearlyData} schoolName={schoolInfo.school_name} isSecondary={true} - nationalAtt8Avg={secondaryAvg.attainment_8_score ?? null} - nationalByYear={nationalAvg?.by_year} + nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)} + nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year} /> @@ -1103,9 +1147,9 @@ export function SchoolDetailView({ data={yearlyData} schoolName={schoolInfo.school_name} isSecondary={isSecondary} - nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null} - nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null} - nationalByYear={nationalAvg?.by_year} + nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null} + nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null} + nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year} /> )} diff --git a/nextjs-app/components/SchoolRow.tsx b/nextjs-app/components/SchoolRow.tsx index 43dc205..b472051 100644 --- a/nextjs-app/components/SchoolRow.tsx +++ b/nextjs-app/components/SchoolRow.tsx @@ -9,7 +9,7 @@ */ import type { School } from '@/lib/types'; -import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils'; +import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils'; import styles from './SchoolRow.module.css'; interface SchoolRowProps { @@ -38,10 +38,19 @@ export function SchoolRow({ school.religious_denomination && school.religious_denomination !== 'Does not apply'; + // Special schools / PRUs / AP: the mainstream RWM measure and its England + // comparison aren't a fair judgement (their pupils have SEND), so a "0% · + // −62 vs national" row misrepresents them. Treat the mainstream stat as not + // comparable — same for a placeholder all-zero value. + const rwmComparable = + school.rwm_expected_pct != null && + school.rwm_expected_pct !== 0 && + !isSpecialSchool(school); + // vs-national delta const rwmDelta = - school.rwm_expected_pct != null && nationalAvgRwm != null - ? Math.round(school.rwm_expected_pct - nationalAvgRwm) + rwmComparable && nationalAvgRwm != null + ? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm) : null; const handleCompareClick = () => { @@ -87,9 +96,9 @@ export function SchoolRow({
- {school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'} + {rwmComparable ? formatPercentage(school.rwm_expected_pct, 0) : '—'} - {school.prev_rwm_expected_pct != null && ( + {rwmComparable && school.prev_rwm_expected_pct != null && ( = 202425; const hasResults = latestResults?.attainment_8_score != null; + // Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND, + // so their headline attainment is far below the mainstream average by design. + // Drop the England comparison + "below" framing so the page doesn't portray + // them as failing against a benchmark that doesn't fit. (Also guards a + // placeholder all-zero Attainment 8.) + const isSpecial = isSpecialSchool(schoolInfo); + const suppressComparison = isSpecial || (latestResults != null && latestResults.attainment_8_score === 0); + const admissionsTag = (() => { const policy = schoolInfo.admissions_policy?.toLowerCase() ?? ''; if (policy.includes('selective')) return 'Selective'; @@ -452,9 +460,18 @@ export function SecondarySchoolDetailView({ GCSE Results ({formatAcademicYear(latestResults.year)})

- GCSE results for Year 11 pupils. England averages shown for comparison. + GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}

+ {isSpecial && ( +
+ This is a special school. Its pupils have special educational + needs and work towards individual targets. They sit the same GCSEs, but their + headline attainment is far below the mainstream average by design, so a comparison + with the England average isn’t a meaningful guide to the school. +
+ )} + {p8Suspended && (
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests @@ -472,7 +489,7 @@ export function SecondarySchoolDetailView({
{latestResults.attainment_8_score.toFixed(1)} - {secondaryAvg.attainment_8_score != null && ( + {!suppressComparison && secondaryAvg.attainment_8_score != null && ( )}
- {secondaryAvg.attainment_8_score != null && ( + {!suppressComparison && secondaryAvg.attainment_8_score != null && (
England avg: {secondaryAvg.attainment_8_score.toFixed(1)}
)}
@@ -512,7 +529,7 @@ export function SecondarySchoolDetailView({
{formatPercentage(latestResults.english_maths_strong_pass_pct)} - {secondaryAvg.english_maths_strong_pass_pct != null && ( + {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && ( )}
- {secondaryAvg.english_maths_strong_pass_pct != null && ( + {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%
)} @@ -534,7 +551,7 @@ export function SecondarySchoolDetailView({
{formatPercentage(latestResults.english_maths_standard_pass_pct)} - {secondaryAvg.english_maths_standard_pass_pct != null && ( + {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && ( )}
- {secondaryAvg.english_maths_standard_pass_pct != null && ( + {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%
)} )} - {/* Attainment 8 visual bar (0–80 scale) */} - {latestResults.attainment_8_score != null && ( + {/* Attainment 8 visual bar (0–80 scale). This viz is explicitly + "school vs national", so it's dropped for special schools where + that comparison isn't meaningful. */} + {!suppressComparison && latestResults.attainment_8_score != null && (
Attainment 8 — school vs national
@@ -743,8 +762,8 @@ export function SecondarySchoolDetailView({ data={yearlyData} schoolName={schoolInfo.school_name} isSecondary={true} - nationalAtt8Avg={heroAtt8Nat} - nationalByYear={nationalAvg?.by_year} + nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat} + nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year} />
diff --git a/nextjs-app/components/SecondarySchoolRow.tsx b/nextjs-app/components/SecondarySchoolRow.tsx index 39c1256..f08d20c 100644 --- a/nextjs-app/components/SecondarySchoolRow.tsx +++ b/nextjs-app/components/SecondarySchoolRow.tsx @@ -11,7 +11,7 @@ 'use client'; import type { School } from '@/lib/types'; -import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils'; +import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils'; import styles from './SecondarySchoolRow.module.css'; function detectAdmissionsTag(school: School): string | null { @@ -55,8 +55,12 @@ export function SecondarySchoolRow({ const ofstedBadge = buildOfstedListBadge(school); const phase = getPhaseStyle(school.phase); const att8 = school.attainment_8_score; + // Special schools / PRUs / AP: Attainment 8 vs the LA average isn't a fair + // comparison (their pupils have SEND), so drop the delta and the number + // rather than show them trailing "the average" by design. + const att8Comparable = att8 != null && att8 !== 0 && !isSpecialSchool(school); const laDelta = - att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null; + att8Comparable && laAvgAttainment8 != null ? (att8 as number) - laAvgAttainment8 : null; const admissionsTag = detectAdmissionsTag(school); const sixthForm = hasSixthForm(school); @@ -106,7 +110,7 @@ export function SecondarySchoolRow({
- {att8 != null ? att8.toFixed(1) : '—'} + {att8Comparable ? (att8 as number).toFixed(1) : '—'} Attainment 8 diff --git a/nextjs-app/components/compare/CompareAcademics.tsx b/nextjs-app/components/compare/CompareAcademics.tsx index 6660a81..1db15b4 100644 --- a/nextjs-app/components/compare/CompareAcademics.tsx +++ b/nextjs-app/components/compare/CompareAcademics.tsx @@ -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; 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) => - 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) => + 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} /> ))}

diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 41b7090..11e4f3f 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -795,3 +795,24 @@ export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close'; export function isProposedToClose(school: { status?: string | null }): boolean { return school.status === PROPOSED_TO_CLOSE_STATUS; } + +/** + * Special schools, pupil referral units and alternative provision teach pupils + * with SEND or outside mainstream settings. Their pupils sit the same KS2/KS4 + * assessments but very few reach the mainstream "expected standard", so the + * headline attainment measures — and any comparison to the England average — + * are not a fair judgement of the school. Callers use this to drop the + * mainstream-benchmark framing (deltas, "below England", national markers) + * rather than portray these schools as failing. + * + * Detection is by establishment type: every DfE special-school type contains + * "special" (e.g. "Community special school", "Academy special converter/sponsor + * led", "Foundation special school", "Non-maintained special school", "Free + * schools special", "Other independent special school"); PRUs and alternative + * provision are matched by name. Special schools carry a mainstream `phase` + * (Primary/Secondary/All-through), so `phase` alone can't identify them. + */ +export function isSpecialSchool(school: { school_type?: string | null }): boolean { + const t = (school.school_type ?? '').toLowerCase(); + return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t); +} From de943ded19b170cd26c8d0008460222bc27670c7 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 20 Jul 2026 21:58:13 +0100 Subject: [PATCH 2/3] fix(review): don't suppress a genuine mainstream 0 in the rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the rankings/search rows treated any exactly-0 attainment as not comparable regardless of school type, so a genuine 0 at a mainstream school rendered as "—" (looks like missing data) and dropped its delta — stricter and inconsistent with SchoolDetailView's placeholder check. - SchoolRow: match SchoolDetailView's ks2Placeholder signature — suppress only when ALL of RWM + reading + writing + maths are 0 (special/suppressed signature), not on a bare rwm === 0. A genuine 0% combined (some pupils met individual subjects but not all three) is not all-zero, so it stays comparable and shows its real figure + trend. - SecondarySchoolRow: Attainment 8 is a single 0–80 score with no subject breakdown to form an all-zero signature, so key off establishment type only (drop the bare att8 === 0 guard). A genuine (if extreme) 0.0 shows its value + LA delta. - For consistency, apply the same to the detail views: drop the bare attainment_8_score === 0 guard (KS4 keys off isSpecial only); KS2 keeps the all-four-subjects-zero placeholder signature. Special schools / PRUs / AP are still handled via isSpecialSchool everywhere. tsc clean; 108/108 unit. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- nextjs-app/components/SchoolDetailView.tsx | 13 ++++++++----- nextjs-app/components/SchoolRow.tsx | 16 +++++++++++----- .../components/SecondarySchoolDetailView.tsx | 8 +++++--- nextjs-app/components/SecondarySchoolRow.tsx | 7 +++++-- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index be5f501..99f7788 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -237,19 +237,22 @@ export function SchoolDetailView({ // reach the mainstream "expected standard", so a 0% headline and an England // comparison portray them as failing against a benchmark that doesn't fit. const isSpecial = isSpecialSchool(schoolInfo); - // Belt-and-braces: a whole-row zero attainment (special or suppressed cohort) - // is a placeholder, not a real mainstream result — suppress its comparison - // too. A single 0 (e.g. 0% exceeding at a normal school) stays comparable. + // Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a + // special/suppressed signature) is a placeholder, not a real result. This + // needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0% + // combined (some pupils met individual subjects but not all three) stays + // comparable. Attainment 8 is a single 0–80 score with no subject breakdown + // to form such a signature, so KS4 keys off establishment type only — a + // genuine (if extreme) 0.0 still shows its real figure and comparison. const ks2Placeholder = latestResults != null && latestResults.rwm_expected_pct === 0 && (latestResults.reading_expected_pct ?? 0) === 0 && (latestResults.writing_expected_pct ?? 0) === 0 && (latestResults.maths_expected_pct ?? 0) === 0; - const ks4Placeholder = latestResults != null && latestResults.attainment_8_score === 0; // Whether to drop the England-average deltas / national markers / "below" // framing on the attainment measures. const suppressKs2Comparison = isSpecial || ks2Placeholder; - const suppressKs4Comparison = isSpecial || ks4Placeholder; + const suppressKs4Comparison = isSpecial; // Build section nav items dynamically — only sections with data. // Order is engagement-led (from section_nav_used analytics): the most-sought diff --git a/nextjs-app/components/SchoolRow.tsx b/nextjs-app/components/SchoolRow.tsx index b472051..dbafd05 100644 --- a/nextjs-app/components/SchoolRow.tsx +++ b/nextjs-app/components/SchoolRow.tsx @@ -40,12 +40,18 @@ export function SchoolRow({ // Special schools / PRUs / AP: the mainstream RWM measure and its England // comparison aren't a fair judgement (their pupils have SEND), so a "0% · - // −62 vs national" row misrepresents them. Treat the mainstream stat as not - // comparable — same for a placeholder all-zero value. + // −62 vs national" row misrepresents them. Also guard a placeholder all-zero + // row (every subject 0 — a special/suppressed signature), matching + // SchoolDetailView's ks2Placeholder. A genuine 0% combined (some pupils met + // individual subjects but not all three) is NOT all-zero, so it stays + // comparable and shows its real figure. + const rwmPlaceholder = + school.rwm_expected_pct === 0 && + (school.reading_expected_pct ?? 0) === 0 && + (school.writing_expected_pct ?? 0) === 0 && + (school.maths_expected_pct ?? 0) === 0; const rwmComparable = - school.rwm_expected_pct != null && - school.rwm_expected_pct !== 0 && - !isSpecialSchool(school); + school.rwm_expected_pct != null && !rwmPlaceholder && !isSpecialSchool(school); // vs-national delta const rwmDelta = diff --git a/nextjs-app/components/SecondarySchoolDetailView.tsx b/nextjs-app/components/SecondarySchoolDetailView.tsx index 071e544..d2716bc 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.tsx +++ b/nextjs-app/components/SecondarySchoolDetailView.tsx @@ -110,10 +110,12 @@ export function SecondarySchoolDetailView({ // Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND, // so their headline attainment is far below the mainstream average by design. // Drop the England comparison + "below" framing so the page doesn't portray - // them as failing against a benchmark that doesn't fit. (Also guards a - // placeholder all-zero Attainment 8.) + // them as failing against a benchmark that doesn't fit. Attainment 8 is a + // single 0–80 score with no subject breakdown to test for a placeholder, so + // this keys off establishment type only — a genuine (if extreme) 0.0 at a + // mainstream school still shows its real value and comparison. const isSpecial = isSpecialSchool(schoolInfo); - const suppressComparison = isSpecial || (latestResults != null && latestResults.attainment_8_score === 0); + const suppressComparison = isSpecial; const admissionsTag = (() => { const policy = schoolInfo.admissions_policy?.toLowerCase() ?? ''; diff --git a/nextjs-app/components/SecondarySchoolRow.tsx b/nextjs-app/components/SecondarySchoolRow.tsx index f08d20c..0477d36 100644 --- a/nextjs-app/components/SecondarySchoolRow.tsx +++ b/nextjs-app/components/SecondarySchoolRow.tsx @@ -57,8 +57,11 @@ export function SecondarySchoolRow({ const att8 = school.attainment_8_score; // Special schools / PRUs / AP: Attainment 8 vs the LA average isn't a fair // comparison (their pupils have SEND), so drop the delta and the number - // rather than show them trailing "the average" by design. - const att8Comparable = att8 != null && att8 !== 0 && !isSpecialSchool(school); + // rather than show them trailing "the average" by design. Attainment 8 is a + // single 0–80 score with no subject breakdown to test for a placeholder, so + // this keys off establishment type only — a genuine (if extreme) 0.0 at a + // mainstream school still shows its real value. + const att8Comparable = att8 != null && !isSpecialSchool(school); const laDelta = att8Comparable && laAvgAttainment8 != null ? (att8 as number) - laAvgAttainment8 : null; From 0b36eace9de55164b89f5be2363a2d5ca63d3658 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 20 Jul 2026 22:09:32 +0100 Subject: [PATCH 3/3] fix(review): accurate PRU/AP copy, unbundle same-school trend, de-dupe note CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review points on the special-schools change: 1. Copy accuracy — the context note said "Its pupils have special educational needs" for every isSpecialSchool() match, but the helper also matches pupil referral units and alternative provision, whose pupils are educated outside a mainstream setting (e.g. after exclusion) and are not necessarily SEND. Extracted a shared with type-aware copy: SEND wording only for genuine special schools; PRUs/AP get their own accurate wording. 2. Same-school trend was conflated with the England comparison — SchoolRow's year-over-year trend arrow (and the school's own figure) were gated on the same flag that drops the vs-England delta, hiding a still-meaningful trend for special schools with real data. Split the two: the school's OWN RWM figure + trend show whenever there's a real value (special schools included; only a placeholder all-zero row is hidden); only the vs-England delta is additionally dropped for special/PRU/AP. Mirrored in SecondarySchoolRow (own Attainment 8 shown; only the vs-LA delta dropped). 3. De-duplicated the .specialNote CSS (was copy-pasted between the two detail view module files) into SpecialSchoolNote.module.css, owned by the shared component so it can't drift. New SpecialSchoolNote unit tests assert SEND wording for special schools and NOT for PRUs/AP. tsc clean; 112/112 unit. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- .../components/SpecialSchoolNote.test.tsx | 33 +++++++++++ .../components/SchoolDetailView.module.css | 18 ------ nextjs-app/components/SchoolDetailView.tsx | 19 ++---- nextjs-app/components/SchoolRow.tsx | 26 ++++----- .../SecondarySchoolDetailView.module.css | 16 ----- .../components/SecondarySchoolDetailView.tsx | 10 +--- nextjs-app/components/SecondarySchoolRow.tsx | 17 +++--- .../components/SpecialSchoolNote.module.css | 16 +++++ nextjs-app/components/SpecialSchoolNote.tsx | 58 +++++++++++++++++++ 9 files changed, 134 insertions(+), 79 deletions(-) create mode 100644 nextjs-app/__tests__/components/SpecialSchoolNote.test.tsx create mode 100644 nextjs-app/components/SpecialSchoolNote.module.css create mode 100644 nextjs-app/components/SpecialSchoolNote.tsx diff --git a/nextjs-app/__tests__/components/SpecialSchoolNote.test.tsx b/nextjs-app/__tests__/components/SpecialSchoolNote.test.tsx new file mode 100644 index 0000000..62c7f25 --- /dev/null +++ b/nextjs-app/__tests__/components/SpecialSchoolNote.test.tsx @@ -0,0 +1,33 @@ +/** + * SpecialSchoolNote Component Tests + */ + +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { SpecialSchoolNote } from '@/components/SpecialSchoolNote'; + +describe('SpecialSchoolNote', () => { + it('renders nothing for a mainstream school', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('describes a special school as teaching pupils with SEND', () => { + render(); + expect(screen.getByText(/This is a special school/i)).toBeInTheDocument(); + expect(screen.getByText(/special educational needs/i)).toBeInTheDocument(); + }); + + it('does NOT call PRU pupils SEND — they are educated outside a mainstream school', () => { + render(); + expect(screen.getByText(/This is a pupil referral unit/i)).toBeInTheDocument(); + expect(screen.getByText(/educated outside a mainstream school/i)).toBeInTheDocument(); + expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument(); + }); + + it('does NOT call alternative-provision pupils SEND', () => { + render(); + expect(screen.getByText(/This is an alternative provision setting/i)).toBeInTheDocument(); + expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument(); + }); +}); diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index cfe453b..b2b419a 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -1558,21 +1558,3 @@ .closingStrip strong { color: #8a6200; } - -/* Special-school context note: soft, informational (teal), not a warning. It - explains why the mainstream attainment measures and England comparison are - dropped for special schools / PRUs / AP. */ -.specialNote { - background: var(--bg-secondary, #f3ede4); - border-left: 4px solid var(--accent-teal, #2d7d7d); - border-radius: 0 6px 6px 0; - padding: 0.7rem 1rem; - margin: 0 0 1rem; - font-size: 0.9rem; - line-height: 1.5; - color: var(--text-secondary, #5c564d); - max-width: 72ch; -} -.specialNote strong { - color: var(--text-primary, #1a1612); -} diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 99f7788..6038f4f 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -21,6 +21,7 @@ import { formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool, } from '@/lib/utils'; import { DeltaChip } from './DeltaChip'; +import { SpecialSchoolNote } from './SpecialSchoolNote'; import { summariseAdmissions } from '@/lib/compareLogic'; const PerformanceChart = dynamic( @@ -656,20 +657,10 @@ export function SchoolDetailView({ : 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}

- {/* Special schools sit the same national assessments but teach pupils - with SEND, so very few reach the mainstream "expected standard". - Explain that up front and drop the England comparison below, so a - 0% headline never reads as a failing grade against a benchmark - that doesn't fit the school. */} - {isSpecial && ( -
- This is a special school. Its pupils have special educational - needs and work towards individual targets. They sit the same national tests, but - very few reach the mainstream “expected standard” these measures report, - so a comparison with the England average isn’t a meaningful guide to the - school. Where available, the progress pupils make (below) is a fairer measure. -
- )} + {/* Explains up front why the England comparison is dropped below, so + a 0% headline never reads as a failing grade against a benchmark + that doesn't fit. Type-aware copy (special vs PRU vs AP). */} + {/* ── Primary / KS2 content ── */} {hasKS2Results && ( diff --git a/nextjs-app/components/SchoolRow.tsx b/nextjs-app/components/SchoolRow.tsx index dbafd05..16e73ad 100644 --- a/nextjs-app/components/SchoolRow.tsx +++ b/nextjs-app/components/SchoolRow.tsx @@ -38,24 +38,22 @@ export function SchoolRow({ school.religious_denomination && school.religious_denomination !== 'Does not apply'; - // Special schools / PRUs / AP: the mainstream RWM measure and its England - // comparison aren't a fair judgement (their pupils have SEND), so a "0% · - // −62 vs national" row misrepresents them. Also guard a placeholder all-zero - // row (every subject 0 — a special/suppressed signature), matching - // SchoolDetailView's ks2Placeholder. A genuine 0% combined (some pupils met - // individual subjects but not all three) is NOT all-zero, so it stays - // comparable and shows its real figure. + // A placeholder all-zero row (every subject 0 — a special/suppressed + // signature, matching SchoolDetailView's ks2Placeholder) isn't a real score, + // so its figure is hidden. A genuine 0% combined (some pupils met individual + // subjects but not all three) is NOT all-zero and stays shown. const rwmPlaceholder = school.rwm_expected_pct === 0 && (school.reading_expected_pct ?? 0) === 0 && (school.writing_expected_pct ?? 0) === 0 && (school.maths_expected_pct ?? 0) === 0; - const rwmComparable = - school.rwm_expected_pct != null && !rwmPlaceholder && !isSpecialSchool(school); - - // vs-national delta + // The school's OWN figure and its year-over-year trend are same-school + // measures — shown whenever there's a real value, special schools included. + const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder; + // The vs-England delta is a mainstream benchmark: additionally dropped for + // special schools / PRUs / AP, whose pupils aren't measured against it fairly. const rwmDelta = - rwmComparable && nationalAvgRwm != null + showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null ? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm) : null; @@ -102,9 +100,9 @@ export function SchoolRow({
- {rwmComparable ? formatPercentage(school.rwm_expected_pct, 0) : '—'} + {showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'} - {rwmComparable && school.prev_rwm_expected_pct != null && ( + {showRwmValue && school.prev_rwm_expected_pct != null && ( - {isSpecial && ( -
- This is a special school. Its pupils have special educational - needs and work towards individual targets. They sit the same GCSEs, but their - headline attainment is far below the mainstream average by design, so a comparison - with the England average isn’t a meaningful guide to the school. -
- )} + {p8Suspended && (
diff --git a/nextjs-app/components/SecondarySchoolRow.tsx b/nextjs-app/components/SecondarySchoolRow.tsx index 0477d36..e10ca0a 100644 --- a/nextjs-app/components/SecondarySchoolRow.tsx +++ b/nextjs-app/components/SecondarySchoolRow.tsx @@ -55,15 +55,14 @@ export function SecondarySchoolRow({ const ofstedBadge = buildOfstedListBadge(school); const phase = getPhaseStyle(school.phase); const att8 = school.attainment_8_score; - // Special schools / PRUs / AP: Attainment 8 vs the LA average isn't a fair - // comparison (their pupils have SEND), so drop the delta and the number - // rather than show them trailing "the average" by design. Attainment 8 is a - // single 0–80 score with no subject breakdown to test for a placeholder, so - // this keys off establishment type only — a genuine (if extreme) 0.0 at a - // mainstream school still shows its real value. - const att8Comparable = att8 != null && !isSpecialSchool(school); + // The school's own Attainment 8 is a same-school figure — shown whenever it + // exists (special schools included; their type tag on line 2 gives context). + // Only the vs-LA-average delta, a benchmark comparison, is dropped for + // special schools / PRUs / AP, whose pupils aren't measured against it fairly. const laDelta = - att8Comparable && laAvgAttainment8 != null ? (att8 as number) - laAvgAttainment8 : null; + att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null + ? att8 - laAvgAttainment8 + : null; const admissionsTag = detectAdmissionsTag(school); const sixthForm = hasSixthForm(school); @@ -113,7 +112,7 @@ export function SecondarySchoolRow({
- {att8Comparable ? (att8 as number).toFixed(1) : '—'} + {att8 != null ? att8.toFixed(1) : '—'} Attainment 8 diff --git a/nextjs-app/components/SpecialSchoolNote.module.css b/nextjs-app/components/SpecialSchoolNote.module.css new file mode 100644 index 0000000..138b39e --- /dev/null +++ b/nextjs-app/components/SpecialSchoolNote.module.css @@ -0,0 +1,16 @@ +/* Special-school / PRU / AP context note: soft, informational (teal), not a + warning. Shared by both detail views so the styling can't drift. */ +.note { + background: var(--bg-secondary, #f3ede4); + border-left: 4px solid var(--accent-teal, #2d7d7d); + border-radius: 0 6px 6px 0; + padding: 0.7rem 1rem; + margin: 0 0 1rem; + font-size: 0.9rem; + line-height: 1.5; + color: var(--text-secondary, #5c564d); + max-width: 72ch; +} +.note strong { + color: var(--text-primary, #1a1612); +} diff --git a/nextjs-app/components/SpecialSchoolNote.tsx b/nextjs-app/components/SpecialSchoolNote.tsx new file mode 100644 index 0000000..f737328 --- /dev/null +++ b/nextjs-app/components/SpecialSchoolNote.tsx @@ -0,0 +1,58 @@ +/** + * SpecialSchoolNote — the context note shown on special-school / PRU / AP + * detail pages explaining why the mainstream England-average comparison is + * dropped. Renders nothing for mainstream schools. + * + * The copy is type-aware: only genuine special schools have pupils with special + * educational needs. Pupil referral units and alternative provision teach + * pupils educated outside a mainstream setting (e.g. after exclusion, or for + * medical reasons) who are not necessarily SEND — so their note says so rather + * than mischaracterising them. Suppressing the England comparison is reasonable + * for all three. + */ + +import { isSpecialSchool } from '@/lib/utils'; +import styles from './SpecialSchoolNote.module.css'; + +type SpecialKind = 'special' | 'pru' | 'ap'; + +function specialKind(schoolType: string | null | undefined): SpecialKind { + const t = (schoolType ?? '').toLowerCase(); + if (/pupil referral/.test(t)) return 'pru'; + if (/alternative provision/.test(t)) return 'ap'; + return 'special'; +} + +export function SpecialSchoolNote({ school }: { school: { school_type?: string | null } }) { + if (!isSpecialSchool(school)) return null; + const kind = specialKind(school.school_type); + + return ( +
+ {kind === 'special' && ( + <> + This is a special school. Its pupils have special educational needs and + work towards individual targets. They sit the same national assessments, but very few + reach the mainstream “expected standard” these measures report — so a + comparison with the England average isn’t a meaningful guide to the school. + + )} + {kind === 'pru' && ( + <> + This is a pupil referral unit. It teaches pupils educated outside a + mainstream school — for example after exclusion, or for medical or behavioural reasons. + The mainstream “expected standard” and the England-average comparison + aren’t a meaningful guide to the school. + + )} + {kind === 'ap' && ( + <> + This is an alternative provision setting. It teaches pupils educated + outside a mainstream school. The mainstream “expected standard” and the + England-average comparison aren’t a meaningful guide to the school. + + )}{' '} + Where available, the progress its pupils make is a fairer measure. +
+ ); +}