Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 4.8 22e9eb2d48 fix(home): make Att8 bar match its numbers and separate safeguarding
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m6s
Two internal-consistency fixes on the "What you'll see" mock:

- The Attainment 8 bar had drifted from the numbers beside it (fill 68%
  / line 57% vs a 54.8 score and 45.9 national avg). Restored the mock's
  original 1:1 value-to-percent mapping: fill 54.8%, national line 45.9%.
- The report card grouped "Safeguarding: Met" in the same five-point
  list as the graded areas, but safeguarding is a separate binary
  met/not-met judgement under the real framework. Moved it below the
  list behind a divider, with an outlined chip and a "separate check"
  label so it no longer reads as a graded area.

Verified with tsc --noEmit and next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 10:01:10 +01:00
TudorandClaude Opus 4.8 13253116c2 fix(home): correct remaining KS2/Ofsted terminology on the landing page
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m35s
Follow-up to the accuracy pass — the medium-severity wording issues
from the same expert review:

- KS2 "Exceeding" isn't a DfE term. The above-expected level is the
  "higher standard" for the reading and maths tests and "greater depth"
  for teacher-assessed writing. Labelled per subject, and brought the
  illustrative reading value down from an implausible 73% (national
  higher standard is ~29%) to a realistic strong-school figure.
- Attributed inspection outcomes to Ofsted, not only the DfE (Ofsted is
  a separate non-ministerial department).
- Hero: "2026/2027 admissions results" -> "the 2026/27 admissions
  round" (admissions produce offers, not results, and we don't hold
  per-school offer outcomes).

Tightened .miniRowHead so the longer "Greater depth" label stays on one
line and the three subject columns keep their bars aligned (the visual
is desktop-only; hidden on mobile).

Verified with tsc --noEmit and next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 09:07:23 +01:00
TudorandClaude Opus 4.8 1b8adb91ee fix(home): correct education/Ofsted claims on the landing page
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 37s
Homepage marketing content had drifted from both the current
education regime and the app's own school pages. An expert accuracy
review surfaced five issues (3 critical, 2 high):

- Ofsted card showed a single "OUTSTANDING" word-grade. Overall grades
  were abolished (Sept 2024) and inspections now produce a report card
  on a five-point scale (from Nov 2025). Rebuilt the card as a report
  card with per-area ratings + stand-alone safeguarding, matching what
  CompareOfsted actually renders on a school page.
- Attainment 8 "National avg 50.2" was wrong; the real 2024/25 figure
  is 45.9. Corrected the benchmark and the illustrative school value.
- Compare mock featured a KS2 "Reading progress" score, but KS2
  progress isn't published for 2023/24 or 2024/25 (no post-COVID KS1
  baseline). Swapped for Higher standard (RWM), which is published.
- Editorial called it "UK school performance data"; the data is
  England-only. Now matches the hero and page metadata.
- Softened the "40-page inspection report" exaggeration.

Landing-page copy isn't covered by e2e journeys, so no test changes.
Verified with tsc --noEmit and next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 09:00:02 +01:00
tudor ca4fc647a5 Merge pull request 'fix(detail): let the special-school note span the full container width' (#72) from fix/special-note-full-width into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 55s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 43s
Reviewed-on: #72
2026-07-20 21:53:27 +00:00
TudorandClaude Opus 4.8 261403bcd2 fix(detail): let the special-school note span the full container width
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
Drop the note's `max-width: 72ch`, which left it stopping well short of the
section's right edge. It now fills the results-section container like the
subtitle paragraph above it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 22:49:11 +01:00
tudor e4c8b86fe7 Merge pull request 'fix(detail): align Ofsted Report Card grade chips' (#71) from fix/report-card-chip-alignment into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #71
2026-07-20 21:32:45 +00:00
tudor 993822d769 Merge pull request 'fix: don’t portray special schools as failing the mainstream benchmark' (#70) from fix/special-schools-not-failing into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 7s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m40s
Reviewed-on: #70
2026-07-20 21:14:57 +00:00
TudorandClaude Opus 4.8 0b36eace9d fix(review): accurate PRU/AP copy, unbundle same-school trend, de-dupe note CSS
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m38s
Three review points on the special-schools change:

1. Copy accuracy — the context note said "Its pupils have special educational
   needs" for every isSpecialSchool() match, but the helper also matches pupil
   referral units and alternative provision, whose pupils are educated outside
   a mainstream setting (e.g. after exclusion) and are not necessarily SEND.
   Extracted a shared <SpecialSchoolNote> with type-aware copy: SEND wording
   only for genuine special schools; PRUs/AP get their own accurate wording.

2. Same-school trend was conflated with the England comparison — SchoolRow's
   year-over-year trend arrow (and the school's own figure) were gated on the
   same flag that drops the vs-England delta, hiding a still-meaningful trend
   for special schools with real data. Split the two: the school's OWN RWM
   figure + trend show whenever there's a real value (special schools
   included; only a placeholder all-zero row is hidden); only the vs-England
   delta is additionally dropped for special/PRU/AP. Mirrored in
   SecondarySchoolRow (own Attainment 8 shown; only the vs-LA delta dropped).

3. De-duplicated the .specialNote CSS (was copy-pasted between the two detail
   view module files) into SpecialSchoolNote.module.css, owned by the shared
   component so it can't drift.

New SpecialSchoolNote unit tests assert SEND wording for special schools and
NOT for PRUs/AP. tsc clean; 112/112 unit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 22:09:32 +01:00
TudorandClaude Opus 4.8 de943ded19 fix(review): don't suppress a genuine mainstream 0 in the rows
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m48s
Review feedback: the rankings/search rows treated any exactly-0 attainment as
not comparable regardless of school type, so a genuine 0 at a mainstream
school rendered as "—" (looks like missing data) and dropped its delta —
stricter and inconsistent with SchoolDetailView's placeholder check.

- SchoolRow: match SchoolDetailView's ks2Placeholder signature — suppress only
  when ALL of RWM + reading + writing + maths are 0 (special/suppressed
  signature), not on a bare rwm === 0. A genuine 0% combined (some pupils met
  individual subjects but not all three) is not all-zero, so it stays
  comparable and shows its real figure + trend.
- SecondarySchoolRow: Attainment 8 is a single 0–80 score with no subject
  breakdown to form an all-zero signature, so key off establishment type only
  (drop the bare att8 === 0 guard). A genuine (if extreme) 0.0 shows its value
  + LA delta.
- For consistency, apply the same to the detail views: drop the bare
  attainment_8_score === 0 guard (KS4 keys off isSpecial only); KS2 keeps the
  all-four-subjects-zero placeholder signature.

Special schools / PRUs / AP are still handled via isSpecialSchool everywhere.
tsc clean; 108/108 unit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 21:58:13 +01:00
TudorandClaude Opus 4.8 ae6ef6860b fix: don't portray special schools as failing the mainstream benchmark
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m8s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m1s
Special schools, PRUs and alternative provision teach pupils with SEND who
sit the same KS2/KS4 assessments but very few reach the mainstream "expected
standard". Their headline attainment is therefore ~0% (or a very low
Attainment 8), and the site was comparing that to the England average and
painting it red — e.g. Greenmead School (a community special school) rendered
as "0.0% — −62 pts below England average" with three 0% red SATs bars. That
portrays a special school as catastrophically failing against a benchmark
that doesn't fit it.

Add a shared `isSpecialSchool()` helper (detects every DfE special-school
establishment type — all contain "special" — plus PRUs / alternative
provision) and drop the mainstream England comparison + "below" framing for
these schools across every surface:

- Detail (primary + secondary): a plain-English context note explaining the
  school is special and why the comparison isn't shown; England-average delta
  chips, "England avg" hints, the SATs national markers, the Attainment-8
  "vs national" bar and the trend chart's England overlay are all suppressed.
  An all-zero placeholder SATs row hides the (empty) subject bar chart and the
  "why is combined lower" bridge.
- Rankings / search rows (primary + secondary): the mainstream RWM / Attainment
  8 stat shows "—" with no "vs national" delta, instead of "0% · −62 vs
  national".
- Compare: special schools' attainment values are dropped (no misleading dot
  at 0% / no "Below England average" chip); progress banding, which IS a fair
  measure for special schools, is kept.

Belt-and-braces zero-guard: a whole-row zero attainment (special or a
suppressed cohort) is also treated as not-comparable, while a legitimate
single 0 (e.g. 0% exceeding at a mainstream school) stays comparable.

Tests: new isSpecialSchool unit tests (every DfE special type matched, no
mainstream false positives); an e2e journey asserts Greenmead shows the
special-school note and no England-average comparison. tsc clean; 108/108 unit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 20:39:16 +01:00
tudor 4e316b4e38 Merge pull request 'fix(detail): label the phase of all-through admissions figures (Batch F review fix)' (#69) from fix/detail-allthrough-admissions-phase into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 56s
Reviewed-on: #69
2026-07-20 16:18:44 +00:00
TudorandClaude Opus 4.8 733faefb98 fix(detail): label which phase all-through admissions figures cover (Batch F)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m9s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 43s
Final Ofsted/education-expert review of the detail page (Batch F) found one
clarity gap: on an all-through school the Admissions block shows a single
entry point's figures (Hessle's are the Year 7 secondary intake —
admissions.school_phase = "Secondary") but is titled just "Admissions", so a
parent interested in the reception/primary intake could read the Year 7
numbers as the whole school.

Add a phase subtitle for all-through schools ("These figures are for secondary
entry (Year 7)." / "...primary entry (Reception)."). Pure-primary and
pure-secondary pages are unambiguous and unchanged.

Rest of the review verified correct against source payloads: all three Ofsted
regimes (report card / ungraded post-Sept-2024 / carried-forward), England-
average anchoring, gap-honest trend charts (KS2 + KS4), all-through dual-phase
rendering, and the mobile layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 17:13:37 +01:00
15 changed files with 462 additions and 106 deletions
+24 -2
View File
@@ -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 }) => {
@@ -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(<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,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');
+5 -5
View File
@@ -13,17 +13,17 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
<div className={styles.editorialGrid}>
<div className={styles.editorialText}>
<div className={styles.editorialKicker}>About school data</div>
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
<h2 className={styles.editorialHeading}>Making England&apos;s school performance data actually readable</h2>
<p>
School performance data in England is rich but fragmented. The Department for Education publishes
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
demographics each in its own table, each with its own jargon.
School performance data in England is rich but fragmented. The Department for Education and Ofsted
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
and demographics each in its own table, each with its own jargon.
</p>
<p>
SchoolCompare brings it all into one place. Every school page shows performance against the national
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
parents, governors, journalists, and anyone who wants to understand a school without reading a
40-page inspection report.
full inspection report.
</p>
</div>
<div className={styles.factbox}>
+66 -14
View File
@@ -720,10 +720,13 @@
.miniRowHead {
display: flex;
justify-content: space-between;
font-size: 0.48rem;
align-items: baseline;
gap: 0.2rem;
font-size: 0.44rem;
color: var(--text-muted, #6d685f);
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.01em;
white-space: nowrap;
}
.miniRowHead strong {
@@ -869,28 +872,77 @@
color: var(--text-primary, #1a1612);
}
.ofstedBadge {
.ofstedFramework {
align-self: flex-start;
padding: 0.2rem 0.55rem;
padding: 0.15rem 0.5rem;
border-radius: 4px;
background: rgba(45, 125, 125, 0.12);
color: var(--accent-teal, #2d7d7d);
font-size: 0.55rem;
background: rgba(90, 58, 110, 0.12);
color: #5a3a6e;
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.ofstedVerdict {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 1.05rem;
.rcList {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.rcRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.rcArea {
font-size: 0.62rem;
color: var(--text-secondary, #4a453d);
}
.rcChip {
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.52rem;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary, #1a1612);
letter-spacing: 0.03em;
white-space: nowrap;
flex-shrink: 0;
}
.ofstedVerdict em {
.rcChipGood {
background: rgba(45, 125, 125, 0.12);
color: var(--accent-teal, #2d7d7d);
font-style: normal;
}
.rcChipNeutral {
background: var(--accent-gold-bg, rgba(180, 150, 40, 0.12));
color: var(--accent-gold-text, #7a6800);
}
/* Safeguarding is a separate binary met/not-met judgement, not a graded area,
so it sits below the five-point list behind a divider with its own chip. */
.rcSafeguarding {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.05rem;
padding-top: 0.35rem;
border-top: 1px dashed var(--border-color, #e5dfd5);
}
.rcSep {
color: var(--text-muted, #8a847a);
font-style: italic;
}
.rcChipMet {
background: transparent;
color: var(--accent-teal, #2d7d7d);
border: 1px solid rgba(45, 125, 125, 0.4);
}
.ofstedMeta {
+1 -1
View File
@@ -261,7 +261,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
<div className={styles.heroSection}>
<span className={styles.heroEyebrow}>
<span className={styles.heroEyebrowDot} aria-hidden="true" />
Updated with 2026/2027 admissions results
Updated for the 2026/27 admissions round
</span>
<h1 className={styles.heroTitle}>
Every school in England, <em className={styles.heroEmph}>compared.</em>
+37 -15
View File
@@ -5,15 +5,26 @@
import styles from './HomeView.module.css';
export function HowItWorksSection() {
// "exc" is the school's above-expected result. DfE names this differently
// by subject: "higher standard" for the reading and maths tests, "greater
// depth" for teacher-assessed writing. "nat" is the national expected %.
const miniCascade = [
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
{ subj: 'Reading', exp: 96, exc: 44, nat: 75, excLabel: 'Higher std' },
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
];
const compareRows = [
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
];
// Report card (Ofsted's framework from Nov 2025): one rating per area of
// school life on a five-point scale. Safeguarding is judged separately, as a
// binary met/not-met outcome — kept out of this list and shown on its own.
const reportCard: Array<{ area: string; grade: string; good: boolean }> = [
{ area: 'Achievement', grade: 'Strong', good: true },
{ area: 'Curriculum & teaching', grade: 'Expected standard', good: false },
{ area: 'Attendance & behaviour', grade: 'Strong', good: true },
];
return (
@@ -29,7 +40,7 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseBlock}>
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
<div className={styles.miniCascade}>
{miniCascade.map(({ subj, exp, exc, nat }) => (
{miniCascade.map(({ subj, exp, exc, nat, excLabel }) => (
<div key={subj} className={styles.miniCascadeCol}>
<div className={styles.miniSubj}>{subj}</div>
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
@@ -37,7 +48,7 @@ export function HowItWorksSection() {
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
</div>
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
<div className={styles.miniRowHead}><span>{excLabel}</span><strong>{exc}%</strong></div>
<div className={styles.miniTrack}>
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
</div>
@@ -49,15 +60,15 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
<div className={styles.att8Row}>
<div className={styles.att8BarWrap}>
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
<div className={styles.att8BarHead}><span>This school</span><span>National avg 45.9</span></div>
<div className={styles.att8Track}>
<div className={styles.att8Fill} style={{ width: '62%' }} />
<div className={styles.att8NatLine} style={{ left: '50%' }} />
<div className={styles.att8Fill} style={{ width: '54.8%' }} />
<div className={styles.att8NatLine} style={{ left: '45.9%' }} />
</div>
</div>
<div className={styles.att8Score}>
<div className={styles.att8Value}>62.4</div>
<div className={styles.att8Delta}>+12.2 vs national</div>
<div className={styles.att8Value}>54.8</div>
<div className={styles.att8Delta}>+8.9 vs national</div>
</div>
</div>
</div>
@@ -77,15 +88,26 @@ export function HowItWorksSection() {
<span className={styles.ofstedBullet} />
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
</div>
<span className={styles.ofstedBadge}>OUTSTANDING</span>
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
<span className={styles.ofstedFramework}>Report card · five-point scale</span>
<div className={styles.rcList}>
{reportCard.map(({ area, grade, good }) => (
<div key={area} className={styles.rcRow}>
<span className={styles.rcArea}>{area}</span>
<span className={`${styles.rcChip} ${good ? styles.rcChipGood : styles.rcChipNeutral}`}>{grade}</span>
</div>
))}
</div>
<div className={styles.rcSafeguarding}>
<span className={styles.rcArea}>Safeguarding <span className={styles.rcSep}>· separate check</span></span>
<span className={`${styles.rcChip} ${styles.rcChipMet}`}>Met</span>
</div>
<div className={styles.ofstedMeta}>Full inspection · March 2026</div>
</div>
</div>
<div className={styles.hiwCardBody}>
<div className={styles.hiwStep}>Judgement</div>
<div className={styles.hiwTitle}>Ofsted at a glance</div>
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline without opening a 40-page report.</p>
<p className={styles.hiwDesc}>Since November 2025, Ofsted rates each area of school life on a five-point scale rather than one overall word. We show every area and the inspection date and, for schools not yet reinspected, the older overall grade they still carry.</p>
</div>
</div>
+67 -20
View File
@@ -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 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.
// 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)})
</h2>
<p className={styles.sectionSubtitle}>
{isAllThrough
{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.'}
</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 ── */}
{hasKS2Results && (
<>
@@ -646,7 +677,7 @@ export function SchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_expected_pct)}
{primaryAvg.rwm_expected_pct != null && (
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
<DeltaChip
value={latestResults.rwm_expected_pct}
baseline={primaryAvg.rwm_expected_pct}
@@ -655,7 +686,7 @@ export function SchoolDetailView({
/>
)}
</div>
{primaryAvg.rwm_expected_pct != null && (
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)}
</div>
@@ -668,7 +699,7 @@ export function SchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_high_pct)}
{primaryAvg.rwm_high_pct != null && (
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
<DeltaChip
value={latestResults.rwm_high_pct}
baseline={primaryAvg.rwm_high_pct}
@@ -677,14 +708,15 @@ export function SchoolDetailView({
/>
)}
</div>
{primaryAvg.rwm_high_pct != null && (
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{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({
</div>
)}
{/* 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 && (
<SatsChart
subjects={[
{
name: 'Reading',
expectedPct: latestResults.reading_expected_pct,
exceedingPct: latestResults.reading_high_pct,
nationalExpectedPct: primaryAvg.reading_expected_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
},
{
name: 'Writing',
expectedPct: latestResults.writing_expected_pct,
exceedingPct: latestResults.writing_high_pct,
nationalExpectedPct: primaryAvg.writing_expected_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
},
{
name: 'Maths',
expectedPct: latestResults.maths_expected_pct,
exceedingPct: latestResults.maths_high_pct,
nationalExpectedPct: primaryAvg.maths_expected_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
},
]}
/>
)}
{/* Progress scores row */}
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
@@ -786,7 +824,7 @@ export function SchoolDetailView({
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{secondaryAvg.attainment_8_score != null && (
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
@@ -810,7 +848,7 @@ export function SchoolDetailView({
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
{secondaryAvg.english_maths_standard_pass_pct != null && (
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
@@ -822,7 +860,7 @@ export function SchoolDetailView({
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
{secondaryAvg.english_maths_strong_pass_pct != null && (
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
@@ -887,6 +925,15 @@ export function SchoolDetailView({
</div>
)}
</div>
{/* All-through admissions data covers a single entry point (usually the
Year 7 secondary intake), not reception — say so, or a parent could
read these as the whole-school figures. */}
{isAllThrough && admissions.school_phase && (
<p className={styles.sectionSubtitle}>
These figures are for {admissions.school_phase.toLowerCase()} entry
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
</p>
)}
<div className={styles.admissionsViewport}>
{/* This-year Q&A */}
@@ -1076,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}
/>
</div>
</>
@@ -1090,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}
/>
</div>
</>
@@ -1103,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}
/>
</div>
)}
+19 -6
View File
@@ -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({
<div className={styles.line3}>
<span className={styles.stat}>
<strong className={styles.statValue}>
{school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
</strong>
{school.prev_rwm_expected_pct != null && (
{showRwmValue && school.prev_rwm_expected_pct != null && (
<span
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
@@ -23,8 +23,9 @@ import type {
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas } from '@/lib/utils';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css';
@@ -107,6 +108,16 @@ export function SecondarySchoolDetailView({
const p8Suspended = latestResults != null && latestResults.year >= 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 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 policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective';
@@ -452,9 +463,11 @@ export function SecondarySchoolDetailView({
GCSE Results ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}>
GCSE results for Year 11 pupils. England averages shown for comparison.
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
</p>
<SpecialSchoolNote school={schoolInfo} />
{p8Suspended && (
<div className={styles.p8Banner}>
Progress 8 isn&apos;t published for 2024/25: this GCSE year group sat no KS2 tests
@@ -472,7 +485,7 @@ export function SecondarySchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{latestResults.attainment_8_score.toFixed(1)}
{secondaryAvg.attainment_8_score != null && (
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
<DeltaChip
value={latestResults.attainment_8_score}
baseline={secondaryAvg.attainment_8_score}
@@ -481,7 +494,7 @@ export function SecondarySchoolDetailView({
/>
)}
</div>
{secondaryAvg.attainment_8_score != null && (
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
@@ -512,7 +525,7 @@ export function SecondarySchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
{secondaryAvg.english_maths_strong_pass_pct != null && (
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_strong_pass_pct}
baseline={secondaryAvg.english_maths_strong_pass_pct}
@@ -521,7 +534,7 @@ export function SecondarySchoolDetailView({
/>
)}
</div>
{secondaryAvg.english_maths_strong_pass_pct != null && (
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
@@ -534,7 +547,7 @@ export function SecondarySchoolDetailView({
</div>
<div className={styles.heroStatValue}>
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
{secondaryAvg.english_maths_standard_pass_pct != null && (
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<DeltaChip
value={latestResults.english_maths_standard_pass_pct}
baseline={secondaryAvg.english_maths_standard_pass_pct}
@@ -543,15 +556,17 @@ export function SecondarySchoolDetailView({
/>
)}
</div>
{secondaryAvg.english_maths_standard_pass_pct != null && (
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* Attainment 8 visual bar (080 scale) */}
{latestResults.attainment_8_score != null && (
{/* Attainment 8 visual bar (080 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 && (
<div className={styles.att8Viz}>
<div className={styles.att8VizLabel}>Attainment 8 school vs national</div>
<div className={styles.att8VizTrack}>
@@ -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}
/>
</div>
</>
+8 -2
View File
@@ -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);
@@ -0,0 +1,15 @@
/* 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);
}
@@ -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 (
<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,6 +8,7 @@
'use client';
import { latestValues, verdict } from '@/lib/compareLogic';
import { isSpecialSchool } from '@/lib/utils';
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
import { DotStrip } from '@/components/DotStrip';
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
@@ -90,15 +91,19 @@ function Strip({
urns,
schoolNames,
national,
special,
}: {
spec: StripSpec;
data: Record<string, ComparisonData>;
urns: number[];
schoolNames: string[];
national: Record<string, number> | undefined;
/** Per-school special-school flag; special schools' mainstream attainment is
* not a fair comparison, so it's dropped from the strip (no dot). */
special: boolean[];
}) {
const values = latestValues(data, urns, spec.metric).map((v) =>
v != null ? Math.round(v) : null,
const values = latestValues(data, urns, spec.metric).map((v, i) =>
v != null && !special[i] ? Math.round(v) : null,
);
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
const anchor =
@@ -135,12 +140,19 @@ export function CompareAcademics({
}) {
const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name);
// Special schools / PRUs / AP: their pupils sit the same assessments but very
// few reach the mainstream standard, so their attainment isn't a fair
// like-for-like comparison — drop it (progress banding, which IS meaningful,
// is kept).
const specialFlags = schools.map((school) => isSpecialSchool(school));
const dropSpecial = (vals: Array<number | null>) =>
vals.map((v, i) => (specialFlags[i] ? null : v));
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
if (isSecondary) {
const att8 = latestValues(data, urns, 'attainment_8_score');
const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score'));
const banding = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
@@ -159,8 +171,8 @@ export function CompareAcademics({
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return y >= 202425;
});
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const grade5 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct'));
const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct'));
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
@@ -272,7 +284,7 @@ export function CompareAcademics({
}
const national = nationalAverages?.primary;
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct'));
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
// groups where single pupils move the percentage — show roughly how many
@@ -307,6 +319,7 @@ export function CompareAcademics({
urns={urns}
schoolNames={schoolNames}
national={national}
special={specialFlags}
/>
))}
@@ -320,6 +333,7 @@ export function CompareAcademics({
urns={urns}
schoolNames={schoolNames}
national={national}
special={specialFlags}
/>
))}
<p className={styles.stripNote}>
+21
View File
@@ -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);
}