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__/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/__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.tsx b/nextjs-app/components/SchoolDetailView.tsx index ce0b207..47c4588 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -18,9 +18,10 @@ 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 { SpecialSchoolNote } from './SpecialSchoolNote'; import { summariseAdmissions } from '@/lib/compareLogic'; const PerformanceChart = dynamic( @@ -233,6 +234,27 @@ 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 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; + // Whether to drop the England-average deltas / national markers / "below" + // framing on the attainment measures. + const suppressKs2Comparison = isSpecial || ks2Placeholder; + const suppressKs4Comparison = isSpecial; + // 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 +646,22 @@ 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.'}

+ {/* 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 && ( <> @@ -646,7 +677,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 +699,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 +740,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 +824,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 +848,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 +860,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)}%
)} @@ -1085,8 +1123,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} /> @@ -1099,8 +1137,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} /> @@ -1112,9 +1150,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..16e73ad 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,23 @@ export function SchoolRow({ school.religious_denomination && school.religious_denomination !== 'Does not apply'; - // vs-national delta + // 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; + // 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 = - school.rwm_expected_pct != null && nationalAvgRwm != null - ? Math.round(school.rwm_expected_pct - nationalAvgRwm) + showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null + ? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm) : null; const handleCompareClick = () => { @@ -87,9 +100,9 @@ export function SchoolRow({
- {school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'} + {showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'} - {school.prev_rwm_expected_pct != null && ( + {showRwmValue && 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. 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; + const admissionsTag = (() => { const policy = schoolInfo.admissions_policy?.toLowerCase() ?? ''; if (policy.includes('selective')) return 'Selective'; @@ -452,9 +463,11 @@ 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.'}

+ + {p8Suspended && (
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests @@ -472,7 +485,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 +525,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 +547,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 +758,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..e10ca0a 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,14 @@ export function SecondarySchoolRow({ const ofstedBadge = buildOfstedListBadge(school); const phase = getPhaseStyle(school.phase); const att8 = school.attainment_8_score; + // 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 = - att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null; + att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null + ? att8 - laAvgAttainment8 + : null; const admissionsTag = detectAdmissionsTag(school); const sixthForm = hasSixthForm(school); 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. +
+ ); +} 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); +}