Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 4.8 21c4add408 fix(e2e): assert all-through phases via visible sub-headings, not tooltip-colliding text
The Batch E all-through journey used `#results getByText('Attainment 8').first()`,
which matched 14 nodes — mostly hidden MetricTooltip labels — so `.first()`
landed on a visibility:hidden node and `toBeVisible()` failed on staging.

Assert instead on the unique, visible all-through sub-headings
("Primary — KS2 SATs" / "Secondary — GCSEs"), which directly prove each
phase block rendered. Full suite green against staging (17/17).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 18:18:52 +01:00
13 changed files with 77 additions and 418 deletions
+3 -23
View File
@@ -172,35 +172,15 @@ test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not ju
// Labelled as all-through in the hero meta. // Labelled as all-through in the hero meta.
await expect(page.getByText(/All-through/i).first()).toBeVisible(); await expect(page.getByText(/All-through/i).first()).toBeVisible();
// The combined results section carries both phases. // The combined results section carries both phases, each under its own
// all-through sub-heading (a unique visible node — unlike "Attainment 8",
// which also appears in hidden MetricTooltip labels).
const results = page.locator('#results'); const results = page.locator('#results');
await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible(); await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible();
await expect(results.getByRole('heading', { name: /Primary.*KS2 SATs/ })).toBeVisible(); // KS2 block await expect(results.getByRole('heading', { name: /Primary.*KS2 SATs/ })).toBeVisible(); // KS2 block
await expect(results.getByRole('heading', { name: /Secondary.*GCSEs/ })).toBeVisible(); // KS4 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 }) => { test('school with no performance data still gets a working detail page', async ({ page }) => {
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form // Schools without KS2/KS4 results (special post-16 institutions, sixth-form
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON // centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
@@ -1,33 +0,0 @@
/**
* 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(<SpecialSchoolNote school={{ school_type: 'Academy converter' }} />);
expect(container).toBeEmptyDOMElement();
});
it('describes a special school as teaching pupils with SEND', () => {
render(<SpecialSchoolNote school={{ school_type: 'Community special school' }} />);
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(<SpecialSchoolNote school={{ school_type: 'Pupil referral unit' }} />);
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(<SpecialSchoolNote school={{ school_type: 'Academy alternative provision converter' }} />);
expect(screen.getByText(/This is an alternative provision setting/i)).toBeInTheDocument();
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
});
});
-38
View File
@@ -269,44 +269,6 @@ 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', () => { describe('shortName', () => {
it('drops the trailing establishment-type words', () => { it('drops the trailing establishment-type words', () => {
expect(shortName('Barclay Primary School')).toBe('Barclay'); expect(shortName('Barclay Primary School')).toBe('Barclay');
@@ -1040,41 +1040,6 @@
color: var(--accent-coral-dark, #b04a2e); color: var(--accent-coral-dark, #b04a2e);
} }
/* ── Ofsted grade grids (Report Card + OEIF) ──
Uniform, vertically-aligned grade chips. Labels reserve two lines so
single- and double-line labels put their chips on the same baseline;
every chip (Met, Strong, Expected standard, …) shares one font size,
padding and min-height regardless of how many lines its text wraps to. */
.gradeGrid .metricCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 0.75rem;
}
.gradeGrid .metricLabel {
min-height: 2.6em;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.gradeGrid .metricValue {
margin-top: auto;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-height: 2.6em;
padding: 0.3rem 0.7rem;
border-radius: 5px;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-align: center;
}
.ofstedDisclaimer { .ofstedDisclaimer {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
+45 -83
View File
@@ -18,10 +18,9 @@ import type {
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { import {
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool, formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas,
} from '@/lib/utils'; } from '@/lib/utils';
import { DeltaChip } from './DeltaChip'; import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
import { summariseAdmissions } from '@/lib/compareLogic'; import { summariseAdmissions } from '@/lib/compareLogic';
const PerformanceChart = dynamic( const PerformanceChart = dynamic(
@@ -234,27 +233,6 @@ export function SchoolDetailView({
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null; const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
const hasAnyResults = hasKS2Results || hasKS4Results; 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 080 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. // Build section nav items dynamically — only sections with data.
// Order is engagement-led (from section_nav_used analytics): the most-sought // Order is engagement-led (from section_nav_used analytics): the most-sought
// sections — results, admissions, inclusion, history — sit near the top, // sections — results, admissions, inclusion, history — sit near the top,
@@ -577,7 +555,7 @@ export function SchoolDetailView({
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas. From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p> </p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{ofsted.rc_safeguarding_met != null && ( {ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div> <div className={styles.metricLabel}>Safeguarding</div>
@@ -623,7 +601,7 @@ export function SchoolDetailView({
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership. Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p> </p>
) : ( ) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{oeifAreas.map(({ label, value }) => ( {oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}> <div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div> <div className={styles.metricLabel}>{label}</div>
@@ -646,22 +624,13 @@ export function SchoolDetailView({
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)}) {isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
</h2> </h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
{isSpecial {isAllThrough
? (isSecondary ? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
? 'GCSE results for Year 11 pupils.' : isSecondary
: 'End-of-primary-school tests taken by Year 6 pupils.') ? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
: isAllThrough : 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
? '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.'}
</p> </p>
{/* 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). */}
<SpecialSchoolNote school={schoolInfo} />
{/* ── Primary / KS2 content ── */} {/* ── Primary / KS2 content ── */}
{hasKS2Results && ( {hasKS2Results && (
<> <>
@@ -677,7 +646,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.heroStatValue}> <div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_expected_pct)} {formatPercentage(latestResults.rwm_expected_pct)}
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && ( {primaryAvg.rwm_expected_pct != null && (
<DeltaChip <DeltaChip
value={latestResults.rwm_expected_pct} value={latestResults.rwm_expected_pct}
baseline={primaryAvg.rwm_expected_pct} baseline={primaryAvg.rwm_expected_pct}
@@ -686,7 +655,7 @@ export function SchoolDetailView({
/> />
)} )}
</div> </div>
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && ( {primaryAvg.rwm_expected_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)} )}
</div> </div>
@@ -699,7 +668,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.heroStatValue}> <div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_high_pct)} {formatPercentage(latestResults.rwm_high_pct)}
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && ( {primaryAvg.rwm_high_pct != null && (
<DeltaChip <DeltaChip
value={latestResults.rwm_high_pct} value={latestResults.rwm_high_pct}
baseline={primaryAvg.rwm_high_pct} baseline={primaryAvg.rwm_high_pct}
@@ -708,15 +677,14 @@ export function SchoolDetailView({
/> />
)} )}
</div> </div>
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && ( {primaryAvg.rwm_high_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
</div> </div>
{!suppressKs2Comparison && {latestResults.rwm_expected_pct != null &&
latestResults.rwm_expected_pct != null &&
latestResults.reading_expected_pct != null && latestResults.reading_expected_pct != null &&
latestResults.writing_expected_pct != null && latestResults.writing_expected_pct != null &&
latestResults.maths_expected_pct != null && ( latestResults.maths_expected_pct != null && (
@@ -740,34 +708,28 @@ export function SchoolDetailView({
</div> </div>
)} )}
{/* All-zero placeholder rows (special / suppressed) would render as <SatsChart
three empty bars against the national markers — misleading, so subjects={[
skip the chart. For a special school with some non-zero {
subjects, keep the bars but drop the national markers. */} name: 'Reading',
{!ks2Placeholder && ( expectedPct: latestResults.reading_expected_pct,
<SatsChart exceedingPct: latestResults.reading_high_pct,
subjects={[ nationalExpectedPct: primaryAvg.reading_expected_pct,
{ },
name: 'Reading', {
expectedPct: latestResults.reading_expected_pct, name: 'Writing',
exceedingPct: latestResults.reading_high_pct, expectedPct: latestResults.writing_expected_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct, exceedingPct: latestResults.writing_high_pct,
}, nationalExpectedPct: primaryAvg.writing_expected_pct,
{ },
name: 'Writing', {
expectedPct: latestResults.writing_expected_pct, name: 'Maths',
exceedingPct: latestResults.writing_high_pct, expectedPct: latestResults.maths_expected_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct, exceedingPct: latestResults.maths_high_pct,
}, nationalExpectedPct: primaryAvg.maths_expected_pct,
{ },
name: 'Maths', ]}
expectedPct: latestResults.maths_expected_pct, />
exceedingPct: latestResults.maths_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
},
]}
/>
)}
{/* Progress scores row */} {/* Progress scores row */}
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && ( {(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
@@ -824,7 +786,7 @@ export function SchoolDetailView({
<MetricTooltip metricKey="attainment_8_score" /> <MetricTooltip metricKey="attainment_8_score" />
</div> </div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div> <div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && ( {secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div> <div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)} )}
</div> </div>
@@ -848,7 +810,7 @@ export function SchoolDetailView({
<MetricTooltip metricKey="english_maths_standard_pass_pct" /> <MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div> </div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div> <div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && ( {secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div> <div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)} )}
</div> </div>
@@ -860,7 +822,7 @@ export function SchoolDetailView({
<MetricTooltip metricKey="english_maths_strong_pass_pct" /> <MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div> </div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div> <div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && ( {secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div> <div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)} )}
</div> </div>
@@ -1123,8 +1085,8 @@ export function SchoolDetailView({
data={yearlyData} data={yearlyData}
schoolName={schoolInfo.school_name} schoolName={schoolInfo.school_name}
isSecondary={false} isSecondary={false}
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)} nationalRwmAvg={primaryAvg.rwm_expected_pct ?? null}
nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year} nationalByYear={nationalAvg?.by_year}
/> />
</div> </div>
</> </>
@@ -1137,8 +1099,8 @@ export function SchoolDetailView({
data={yearlyData} data={yearlyData}
schoolName={schoolInfo.school_name} schoolName={schoolInfo.school_name}
isSecondary={true} isSecondary={true}
nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)} nationalAtt8Avg={secondaryAvg.attainment_8_score ?? null}
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year} nationalByYear={nationalAvg?.by_year}
/> />
</div> </div>
</> </>
@@ -1150,9 +1112,9 @@ export function SchoolDetailView({
data={yearlyData} data={yearlyData}
schoolName={schoolInfo.school_name} schoolName={schoolInfo.school_name}
isSecondary={isSecondary} isSecondary={isSecondary}
nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null} nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null} nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year} nationalByYear={nationalAvg?.by_year}
/> />
</div> </div>
)} )}
+6 -19
View File
@@ -9,7 +9,7 @@
*/ */
import type { School } from '@/lib/types'; import type { School } from '@/lib/types';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils'; import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils';
import styles from './SchoolRow.module.css'; import styles from './SchoolRow.module.css';
interface SchoolRowProps { interface SchoolRowProps {
@@ -38,23 +38,10 @@ export function SchoolRow({
school.religious_denomination && school.religious_denomination &&
school.religious_denomination !== 'Does not apply'; school.religious_denomination !== 'Does not apply';
// A placeholder all-zero row (every subject 0 — a special/suppressed // vs-national delta
// 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 = const rwmDelta =
showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null school.rwm_expected_pct != null && nationalAvgRwm != null
? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm) ? Math.round(school.rwm_expected_pct - nationalAvgRwm)
: null; : null;
const handleCompareClick = () => { const handleCompareClick = () => {
@@ -100,9 +87,9 @@ export function SchoolRow({
<div className={styles.line3}> <div className={styles.line3}>
<span className={styles.stat}> <span className={styles.stat}>
<strong className={styles.statValue}> <strong className={styles.statValue}>
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'} {school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
</strong> </strong>
{showRwmValue && school.prev_rwm_expected_pct != null && ( {school.prev_rwm_expected_pct != null && (
<span <span
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`} className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`} title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
@@ -623,41 +623,6 @@
color: var(--accent-coral-dark, #b04a2e); color: var(--accent-coral-dark, #b04a2e);
} }
/* ── Ofsted grade grids (Report Card + OEIF) ──
Uniform, vertically-aligned grade chips. Labels reserve two lines so
single- and double-line labels put their chips on the same baseline;
every chip (Met, Strong, Expected standard, …) shares one font size,
padding and min-height regardless of how many lines its text wraps to. */
.gradeGrid .metricCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 0.75rem;
}
.gradeGrid .metricLabel {
min-height: 2.6em;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.gradeGrid .metricValue {
margin-top: auto;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-height: 2.6em;
padding: 0.3rem 0.7rem;
border-radius: 5px;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-align: center;
}
.ofstedDisclaimer { .ofstedDisclaimer {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
@@ -23,9 +23,8 @@ import type {
SchoolAdmissions, SenDetail, Phonics, SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils'; import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas } from '@/lib/utils';
import { DeltaChip } from './DeltaChip'; import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
import { track, getNavigationSource } from '@/lib/analytics'; import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css'; import styles from './SecondarySchoolDetailView.module.css';
@@ -108,16 +107,6 @@ export function SecondarySchoolDetailView({
const p8Suspended = latestResults != null && latestResults.year >= 202425; const p8Suspended = latestResults != null && latestResults.year >= 202425;
const hasResults = latestResults?.attainment_8_score != null; 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 080 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 admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? ''; const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective'; if (policy.includes('selective')) return 'Selective';
@@ -374,7 +363,7 @@ export function SecondarySchoolDetailView({
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas. From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p> </p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{ofsted.rc_safeguarding_met != null && ( {ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div> <div className={styles.metricLabel}>Safeguarding</div>
@@ -419,7 +408,7 @@ export function SecondarySchoolDetailView({
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership. Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p> </p>
) : ( ) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{oeifAreas.map(({ label, value }) => ( {oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}> <div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div> <div className={styles.metricLabel}>{label}</div>
@@ -436,7 +425,7 @@ export function SecondarySchoolDetailView({
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade. From September 2024, Ofsted no longer gives a single overall grade.
</p> </p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{[ {[
{ label: 'Quality of Education', value: ofsted.quality_of_education }, { label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes }, { label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
@@ -463,11 +452,9 @@ export function SecondarySchoolDetailView({
GCSE Results ({formatAcademicYear(latestResults.year)}) GCSE Results ({formatAcademicYear(latestResults.year)})
</h2> </h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'} GCSE results for Year 11 pupils. England averages shown for comparison.
</p> </p>
<SpecialSchoolNote school={schoolInfo} />
{p8Suspended && ( {p8Suspended && (
<div className={styles.p8Banner}> <div className={styles.p8Banner}>
Progress 8 isn&apos;t published for 2024/25: this GCSE year group sat no KS2 tests Progress 8 isn&apos;t published for 2024/25: this GCSE year group sat no KS2 tests
@@ -485,7 +472,7 @@ export function SecondarySchoolDetailView({
</div> </div>
<div className={styles.heroStatValue}> <div className={styles.heroStatValue}>
{latestResults.attainment_8_score.toFixed(1)} {latestResults.attainment_8_score.toFixed(1)}
{!suppressComparison && secondaryAvg.attainment_8_score != null && ( {secondaryAvg.attainment_8_score != null && (
<DeltaChip <DeltaChip
value={latestResults.attainment_8_score} value={latestResults.attainment_8_score}
baseline={secondaryAvg.attainment_8_score} baseline={secondaryAvg.attainment_8_score}
@@ -494,7 +481,7 @@ export function SecondarySchoolDetailView({
/> />
)} )}
</div> </div>
{!suppressComparison && secondaryAvg.attainment_8_score != null && ( {secondaryAvg.attainment_8_score != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div> <div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)} )}
</div> </div>
@@ -525,7 +512,7 @@ export function SecondarySchoolDetailView({
</div> </div>
<div className={styles.heroStatValue}> <div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_strong_pass_pct)} {formatPercentage(latestResults.english_maths_strong_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && ( {secondaryAvg.english_maths_strong_pass_pct != null && (
<DeltaChip <DeltaChip
value={latestResults.english_maths_strong_pass_pct} value={latestResults.english_maths_strong_pass_pct}
baseline={secondaryAvg.english_maths_strong_pass_pct} baseline={secondaryAvg.english_maths_strong_pass_pct}
@@ -534,7 +521,7 @@ export function SecondarySchoolDetailView({
/> />
)} )}
</div> </div>
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && ( {secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)} )}
</div> </div>
@@ -547,7 +534,7 @@ export function SecondarySchoolDetailView({
</div> </div>
<div className={styles.heroStatValue}> <div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_standard_pass_pct)} {formatPercentage(latestResults.english_maths_standard_pass_pct)}
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && ( {secondaryAvg.english_maths_standard_pass_pct != null && (
<DeltaChip <DeltaChip
value={latestResults.english_maths_standard_pass_pct} value={latestResults.english_maths_standard_pass_pct}
baseline={secondaryAvg.english_maths_standard_pass_pct} baseline={secondaryAvg.english_maths_standard_pass_pct}
@@ -556,17 +543,15 @@ export function SecondarySchoolDetailView({
/> />
)} )}
</div> </div>
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && ( {secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
</div> </div>
{/* Attainment 8 visual bar (080 scale). This viz is explicitly {/* Attainment 8 visual bar (080 scale) */}
"school vs national", so it's dropped for special schools where {latestResults.attainment_8_score != null && (
that comparison isn't meaningful. */}
{!suppressComparison && latestResults.attainment_8_score != null && (
<div className={styles.att8Viz}> <div className={styles.att8Viz}>
<div className={styles.att8VizLabel}>Attainment 8 school vs national</div> <div className={styles.att8VizLabel}>Attainment 8 school vs national</div>
<div className={styles.att8VizTrack}> <div className={styles.att8VizTrack}>
@@ -758,8 +743,8 @@ export function SecondarySchoolDetailView({
data={yearlyData} data={yearlyData}
schoolName={schoolInfo.school_name} schoolName={schoolInfo.school_name}
isSecondary={true} isSecondary={true}
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat} nationalAtt8Avg={heroAtt8Nat}
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year} nationalByYear={nationalAvg?.by_year}
/> />
</div> </div>
</> </>
+2 -8
View File
@@ -11,7 +11,7 @@
'use client'; 'use client';
import type { School } from '@/lib/types'; import type { School } from '@/lib/types';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils'; import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils';
import styles from './SecondarySchoolRow.module.css'; import styles from './SecondarySchoolRow.module.css';
function detectAdmissionsTag(school: School): string | null { function detectAdmissionsTag(school: School): string | null {
@@ -55,14 +55,8 @@ export function SecondarySchoolRow({
const ofstedBadge = buildOfstedListBadge(school); const ofstedBadge = buildOfstedListBadge(school);
const phase = getPhaseStyle(school.phase); const phase = getPhaseStyle(school.phase);
const att8 = school.attainment_8_score; 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 = const laDelta =
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
? att8 - laAvgAttainment8
: null;
const admissionsTag = detectAdmissionsTag(school); const admissionsTag = detectAdmissionsTag(school);
const sixthForm = hasSixthForm(school); const sixthForm = hasSixthForm(school);
@@ -1,15 +0,0 @@
/* 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);
}
.note strong {
color: var(--text-primary, #1a1612);
}
@@ -1,58 +0,0 @@
/**
* 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 (
<div className={styles.note} role="note">
{kind === 'special' && (
<>
<strong>This is a special school.</strong> Its pupils have special educational needs and
work towards individual targets. They sit the same national assessments, but very few
reach the mainstream &ldquo;expected standard&rdquo; these measures report so a
comparison with the England average isn&rsquo;t a meaningful guide to the school.
</>
)}
{kind === 'pru' && (
<>
<strong>This is a pupil referral unit.</strong> It teaches pupils educated outside a
mainstream school for example after exclusion, or for medical or behavioural reasons.
The mainstream &ldquo;expected standard&rdquo; and the England-average comparison
aren&rsquo;t a meaningful guide to the school.
</>
)}
{kind === 'ap' && (
<>
<strong>This is an alternative provision setting.</strong> It teaches pupils educated
outside a mainstream school. The mainstream &ldquo;expected standard&rdquo; and the
England-average comparison aren&rsquo;t a meaningful guide to the school.
</>
)}{' '}
Where available, the progress its pupils make is a fairer measure.
</div>
);
}
@@ -8,7 +8,6 @@
'use client'; 'use client';
import { latestValues, verdict } from '@/lib/compareLogic'; import { latestValues, verdict } from '@/lib/compareLogic';
import { isSpecialSchool } from '@/lib/utils';
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types'; import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
import { DotStrip } from '@/components/DotStrip'; import { DotStrip } from '@/components/DotStrip';
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
@@ -91,19 +90,15 @@ function Strip({
urns, urns,
schoolNames, schoolNames,
national, national,
special,
}: { }: {
spec: StripSpec; spec: StripSpec;
data: Record<string, ComparisonData>; data: Record<string, ComparisonData>;
urns: number[]; urns: number[];
schoolNames: string[]; schoolNames: string[];
national: Record<string, number> | undefined; national: Record<string, number> | undefined;
/** Per-school special-school flag; special schools' mainstream attainment is
* not a fair comparison, so it's dropped from the strip (no dot). */
special: boolean[];
}) { }) {
const values = latestValues(data, urns, spec.metric).map((v, i) => const values = latestValues(data, urns, spec.metric).map((v) =>
v != null && !special[i] ? Math.round(v) : null, v != null ? Math.round(v) : null,
); );
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined; const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
const anchor = const anchor =
@@ -140,19 +135,12 @@ export function CompareAcademics({
}) { }) {
const urns = schools.map((school) => school.urn); const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name); const schoolNames = schools.map((school) => school.school_name);
// Special schools / PRUs / AP: their pupils sit the same assessments but very
// few reach the mainstream standard, so their attainment isn't a fair
// like-for-like comparison — drop it (progress banding, which IS meaningful,
// is kept).
const specialFlags = schools.map((school) => isSpecialSchool(school));
const dropSpecial = (vals: Array<number | null>) =>
vals.map((v, i) => (specialFlags[i] ? null : v));
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some( const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
); );
if (isSecondary) { if (isSecondary) {
const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score')); const att8 = latestValues(data, urns, 'attainment_8_score');
const banding = urns.map((urn) => { const banding = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? []; const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) { for (let i = rows.length - 1; i >= 0; i--) {
@@ -171,8 +159,8 @@ export function CompareAcademics({
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0; const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return y >= 202425; return y >= 202425;
}); });
const grade5 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct')); const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct')); const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score; const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct; const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct; const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
@@ -284,7 +272,7 @@ export function CompareAcademics({
} }
const national = nationalAverages?.primary; const national = nationalAverages?.primary;
const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct')); const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null; const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
// Cohort size behind the disadvantaged figure (spec §8.5): these are small // Cohort size behind the disadvantaged figure (spec §8.5): these are small
// groups where single pupils move the percentage — show roughly how many // groups where single pupils move the percentage — show roughly how many
@@ -319,7 +307,6 @@ export function CompareAcademics({
urns={urns} urns={urns}
schoolNames={schoolNames} schoolNames={schoolNames}
national={national} national={national}
special={specialFlags}
/> />
))} ))}
@@ -333,7 +320,6 @@ export function CompareAcademics({
urns={urns} urns={urns}
schoolNames={schoolNames} schoolNames={schoolNames}
national={national} national={national}
special={specialFlags}
/> />
))} ))}
<p className={styles.stripNote}> <p className={styles.stripNote}>
-21
View File
@@ -795,24 +795,3 @@ export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
export function isProposedToClose(school: { status?: string | null }): boolean { export function isProposedToClose(school: { status?: string | null }): boolean {
return school.status === PROPOSED_TO_CLOSE_STATUS; 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);
}