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); +}