Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 4.8 8a9ba30cc2 fix(detail): compare each SATs bar to its own national benchmark
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 18s
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 9s
The KS2 SATs chart drew a single national-average line spanning the
full height of each subject's chart area, positioned at the national
*expected* value. But the area stacks two bars — Expected and Exceeding
— and the higher-standard/greater-depth national is a very different,
much lower figure (e.g. reading higher standard ~29% vs expected ~75%).
So the line crossed the Exceeding bar at the wrong place, making every
school's exceeding result look far below national when it wasn't.

The per-subject higher-standard nationals were already computed in the
fact_ks2_national_averages mart; they just weren't serialized. Fix:

- backend: add reading_high_pct, writing_gd_pct (writing = greater
  depth) and maths_high_pct to the national-averages payload.
- SchoolDetailView: pass a nationalExceedingPct per subject, mapping
  writing to the greater-depth figure.
- SatsChart: replace the single full-height line with a national marker
  on each bar's own track (coral tick + "nat X%" in the bar header), so
  Expected and Exceeding each sit against the correct benchmark.

KS2 only; the secondary Attainment 8 chart already uses one line for
one measure and is untouched.

Verified: tsc --noEmit, next build, and backend pytest (national
averages marts, incl. a new test guarding the per-subject nationals).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 14:55:43 +01:00
tudor 388b496cc5 Merge pull request 'feat(admissions): surface easy-to-miss deadlines as callout boxes' (#75) from fix/rankings-admissions-accuracy 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 51s
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 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #75
2026-07-21 11:27:20 +00:00
TudorandClaude Opus 4.8 15da060e4a feat(admissions): surface easy-to-miss deadlines as callout boxes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
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 9s
The selective-school registration and faith-school SIF warnings were
added as prose in the criteria steps, where they read as ordinary
sentences and were easy to skim past. Pulled them into structured
callout boxes with an amber "note" treatment (using the page's own
gold accent tokens), a warning glyph, and a bold lead-in — so the two
deadlines parents most often miss actually stand out.

- Added a `callouts` field to Step; render as a callout group between
  the body and the tool link (both primary and secondary timelines).
- Reverted the warning text back out of the step bodies so it isn't
  duplicated.

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 12:22:49 +01:00
tudor 5981916b82 Merge pull request 'fix(rankings,admissions): correct education-accuracy audit findings' (#74) from fix/rankings-admissions-accuracy 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 50s
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 44s
Reviewed-on: #74
2026-07-21 11:16:14 +00:00
TudorandClaude Opus 4.8 a5b2296240 fix(rankings,admissions): correct accuracy issues from education audit
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 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 1m11s
Follow-up audit of the rankings and admissions pages.

Admissions (AdmissionsView.tsx):
- National Offer Day no longer claims offers publish "from 12:01 am";
  release times are set per-council (often late afternoon, some
  overnight), so it now tells parents to check their council's page.
- Secondary preference count no longer states a flat "up to six"
  (that's London/Pan-London); most LAs allow three to six. Mirrors the
  hedge the primary step already used.
- Added the two deadlines that most often catch parents out: selective
  schools' separate entrance-test registration (months earlier), and
  faith schools' supplementary information form sent direct to the
  school. Covered in both the primary and secondary criteria steps.
- Reworded the equal-preference tip so it's precise: order is the
  tie-break among schools you qualify for (you get the highest-ranked
  one), not irrelevant.

Rankings (RankingsView.tsx):
- Subtitle "Top-performing schools by X" -> "Schools ranked by X", so
  it isn't nonsensical for context/equity/absence metrics.
- KS2 progress isn't published for 2023/24 or 2024/25 (no KS1
  baseline). Selecting a primary progress metric on a recent year used
  to dead-end on a generic "No rankings found". Added a scoped caveat
  to the progress hint and an explanatory empty-state (primary only —
  secondary Progress 8 is published).

Added an admissions smoke journey (static content, stable milestones).
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 12:08:50 +01:00
tudor af23fbf27e Merge pull request 'fix(home): correct education/Ofsted claims on the landing page' (#73) from fix/homepage-education-accuracy 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 51s
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: #73
2026-07-21 09:04:57 +00:00
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
TudorandClaude Opus 4.8 1c1df77961 fix(detail): align Ofsted Report Card grade chips
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
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 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 8s
The grade boxes rendered inconsistently: Safeguarding "Met" was a small
0.8125rem inline pill while grade values ("Strong") were 1.25rem full-width
chips, and because category labels wrap to one or two lines each value sat at
a different vertical position. Two-line values like "Expected standard" also
produced a taller box.

Add a scoped `.gradeGrid` modifier (Report Card + OEIF grids in both the
primary and secondary detail views) that reserves two label lines so every
chip shares a baseline, and gives all value chips one font size, padding and
min-height — long text wraps inside an equal-height chip via max-width:100%.
Scoped rather than touching the shared `.metricCard`/`.metricValue` used by
the finance, phonics and admissions sections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 22:31:58 +01: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
tudor 5e370d09f1 Merge pull request 'fix(detail): render both phases for all-through schools (Batch E)' (#68) from fix/detail-all-through 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 48s
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) Failing after 56s
Reviewed-on: #68
2026-07-20 12:02:08 +00:00
TudorandClaude Opus 4.8 f6bb037c47 fix(detail): render both phases for all-through schools (Batch E)
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 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 57s
Batch E of applying the compare-screen learnings to the school detail page —
all-through handling (point 11).

An all-through school carries both KS2 and KS4 figures in the same yearly
rows, but SchoolDetailView flipped it to isSecondary and rendered GCSE-only,
hiding the entire primary phase (SATs, phonics, KS2 trend). The Results
snapshot already gated its KS2/KS4 blocks purely on data availability, so both
already appeared there — but the section title, the trend chart, phonics, the
nav label and the history table all still assumed a single phase.

- Add an explicit `isAllThrough` flag (+ `showPrimaryContent = isPrimary ||
  isAllThrough`); pure-secondary behaviour is unchanged.
- Hero: an "All-through (primary & secondary)" meta chip for all-ages framing.
- Results section: title "SATs & GCSE Results", a combined subtitle, and
  "Primary — KS2 SATs (Year 6)" / "Secondary — GCSEs (Year 11)" sub-headings
  separating the two blocks.
- Results Over Time: render two stacked PerformanceCharts (KS2 SATs + GCSEs)
  rather than crowding both stages' series — on different scales with
  different gap stories — onto one axis. Each gets its correct England overlay.
- Phonics section + nav item now show for all-through (primary-stage metric).
- History table: an all-through column set covering both phases (RWM, Exceeding,
  Attainment 8, Progress 8, Eng & Maths 4+).
- Nav "Results" label for all-through instead of "GCSEs".

e2e: a new journey asserts an all-through school (Hessle, 137306) shows both
the KS2 and KS4 results and the All-through label, with a data-driven
precondition skip if staging data drifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 12:53:07 +01:00
tudor 9c93c3d9c2 Merge pull request 'fix(detail): gap-honest year axis on the detail-page trend charts (Batch D)' (#67) from fix/detail-chart-truthfulness 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 56s
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) Successful in 44s
Reviewed-on: #67
2026-07-20 11:42:48 +00:00
TudorandClaude Opus 4.8 17bd4d5a5e fix(detail): gap-honest year axis on the detail-page trend charts (Batch D)
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 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m38s
Applies the compare screen's chart-truthfulness rules (spec §8.1) to the
school detail page's time-series charts.

PerformanceChart (Results Over Time, both phases):
- Fill every academic year between the first and last data point via the
  shared fillAcademicYears helper, so cancelled/unpublished years (2019/20,
  2020/21, and — for KS2 — 2021/22) render as real gaps instead of
  compressed time. Each series and the England overlay map onto this filled
  axis with null for missing years; spanGaps:false so school lines never
  bridge a gap.
- Replace the primary-only COVID note with a distinct, honest gap caption:
  KS2 names the cancelled tests plus the unpublished 2021/22 school-level
  year; KS4 names the unpublished 2019/20–2020/21 GCSE grading years.
- Mobile x-axis switches to autoSkip so the longer (gap-honest) axis stays
  readable; the broken line still marks a missing year even when its tick
  label is skipped.

AdmissionsTrendChart:
- Same gap-honest axis + spanGaps:false so a missing admissions year is a
  real gap, not compressed time.

Point 12 (definite canvas heights): desktop is already a definite 280px;
fix the secondary detail's mobile .chartContainer, which fixed the outer
box at 220px and double-constrained PerformanceChart's own 220px canvas +
chip strip (clipping the chips onto the plot) — now height:auto to match
the primary view.

SatsChart is out of scope (single-year per-subject CSS bars — no year axis,
no canvas height to constrain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 12:19:37 +01:00
tudor 31ae13451a Merge pull request 'fix(detail): banded admissions + plain-English Progress 8 (Batch C)' (#66) from fix/detail-inclusion-admissions into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
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 42s
Reviewed-on: #66
2026-07-20 11:11:39 +00:00
TudorandClaude Fable 5 4e6be0ce65 fix(detail): banded first-choice interpretation + plain-English P8 absence copy
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 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 24s
Batch C (points 7-10):
- Point 7: the admissions section shows the first-choice offer rate but no
  sense of how severe a low rate is. Reuse the compare screen's banded
  summariseAdmissions ('More than half of first choices missed out' etc.)
  as an interpretive line under the tiles. (Selective/faith framing was
  already present via admissionsTag.)
- Point 8: replace the jargon P8-suspended banner ('not used for
  accountability purposes ... treat with caution') with the compare
  screen's plain-English 'no KS2 baseline (COVID)' explanation.
- Points 9 (disadvantaged cohort sizes) and 10 (FSM benchmark) don't apply
  to the detail page: it shows disadvantaged composition, not disadvantaged
  attainment, and no FSM-vs-benchmark comparison — so there's no small-cohort
  caveat or cross-definition fallback to fix here.
- Also fixed a stray lowercase 'national avg' provenance string Batch B
  missed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 12:03:23 +01:00
tudor 452ec77449 Merge pull request 'fix(detail): 'England average' provenance labelling (Batch B)' (#65) from fix/detail-provenance-anchoring 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 48s
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 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 43s
Reviewed-on: #65
2026-07-20 07:52:55 +00:00
tudor aa87fa917d Merge pull request 'fix(detail): Ofsted sentinel codes, sixth form, carried-forward labels (Batch A)' (#64) from fix/detail-ofsted-correctness 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 49s
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 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 42s
Reviewed-on: #64
2026-07-20 06:21:06 +00:00
TudorandClaude Fable 5 e36125b24a fix(detail): label official DfE figures as 'England average' (provenance)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m0s
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 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
Batch B (points 5-6): the detail page called every national figure a
'National avg'. All of them come from the official-DfE national-averages
marts (KS2/KS4 headlines), so they are England averages — relabel to
match the compare screen's provenance convention ('England average' for
official figures; the detail page has no computed benchmarks, so no
'state-school average (computed)' label is needed). Point 6 (anchoring
every number) is already satisfied on the detail page via DeltaChip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 07:19:22 +01:00
TudorandClaude Fable 5 fdda52ff0a fix(detail): filter Ofsted sentinel codes, show sixth-form judgement, label carried-forward grades
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 11s
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 1m23s
Batch A of applying compare-screen learnings to the detail page (points 2-3):

- Point 2: the legacy OEIF sub-judgement grid rendered any non-null value
  via OFSTED_LABELS[value], so Ofsted's sentinel code 9 ('not applicable')
  leaked as an undefined label, and the sixth-form judgement was dropped
  entirely. New shared ofstedLegacyAreas() helper returns only real grades
  (1-4) and includes Sixth Form provision. Both detail views + the
  all-same-grade check use it.
- Point 3: the OEIF disclaimer always claimed 'Ofsted no longer makes an
  overall judgement' even when showing a carried-forward grade. It's now
  grade_source-aware and labels carried-forward grades as such, matching
  the compare screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 07:15:33 +01:00
tudor a2c63b3cda Merge pull request 'fix(list/map): report-card schools badged as Report Card, not their old grade' (#63) from fix/list-map-report-card-signal into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 54s
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 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 41s
Reviewed-on: #63
2026-07-18 20:28:19 +00:00
TudorandClaude Fable 5 b2b2cad5ac test/docs: harden report-card list e2e + correct badge docstring
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 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 1m4s
Review fixes on the list/map report-card PR:
- e2e precondition now hard-asserts ofsted_rc_date instead of test.skip, so
  the backend dropping the field fails loudly (that's the regression under
  test), not silently skips.
- Use page_size=5 (the real backend param); per_page was ignored and fell
  back to the default page size.
- Update buildOfstedListBadge docstring to describe the ofsted_rc_date-based,
  report-card-wins-first detection instead of the removed framework check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 21:24:09 +01:00
TudorandClaude Fable 5 5ec4f3f7cd fix(list/map): badge report-card schools as Report Card, not their carried-forward grade
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m48s
The search-result cards and map pins keyed report-card detection off
ofsted_framework === 'ReportCard', but the API sets ofsted_framework to the
raw event grouping ('Schools - S5'); worse, ofsted_grade (the carried-forward
legacy grade) was checked first and won. So report-card schools were badged
by their old grade — Barclay's pin/card read 'Outstanding · 2021' instead of
'Report Card · 2026'. Same root cause as the detail-page fix, different
surface.

Expose ofsted_rc_date on the list serialization (the report-card inspection
date, non-null only for report cards) and make both badge builders treat a
present rc-date as winning over any grade, using its year. Removes the dead
framework === 'ReportCard' branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 21:14:57 +01:00
tudor 74bdbebd57 Merge pull request 'fix(detail): render & correctly date Ofsted report cards' (#62) from fix/detail-report-card-render-date 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 54s
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 19s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 40s
Reviewed-on: #62
2026-07-18 18:13:25 +00:00
TudorandClaude Fable 5 b31e71ac88 fix(detail): render report cards and date them by the report-card inspection
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
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 55s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m2s
The detail views detected report cards via ofsted.framework === 'ReportCard',
but the API sets framework to the raw event grouping ('Schools - S5') even
for report-card schools — so the check never matched: report-card schools
rendered as legacy 'Ofsted Rating' badges dated to a pre-Nov-2025
inspection (e.g. Barclay shown as 'Outstanding, Inspected 7 October 2021'
instead of its Feb 2026 report card).

Detect report cards by the presence of the report_card object (matching
the compare screen), and date them with rc_inspection_date, never the
legacy inspection_date. Applies to both primary and secondary detail views.
New e2e assertion against a real report-card school gates it.

Note: the list/map surface has the same latent issue via ofsted_framework
(LeafletMapInner) — flagged as a separate follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 19:09:16 +01:00
tudor b05f3bebc7 Merge pull request 'fix(ci): promotion E2E-gate check uses Actions token, not REGISTRY_TOKEN (403)' (#61) from fix/promote-status-token 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 54s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #61
2026-07-18 15:25:28 +00:00
TudorandClaude Fable 5 ce422e6436 fix(ci): use the Actions token, not REGISTRY_TOKEN, to read the E2E commit status
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
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 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 36s
Promotion's 'Verify the staging E2E gate' step called the Gitea
commit-status API with REGISTRY_TOKEN, which has container-registry scope
but no repository scope — so the API returned 403 and promotion failed.
Switch to the built-in GITHUB_TOKEN (repo read scope), matching how
pr-checks.yml already authenticates to the Gitea API. REGISTRY_TOKEN is
still used for the docker registry login, which is its correct scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 16:20:03 +01:00
tudor 2ac26acf91 Merge pull request 'style(compare): shorten Ofsted link text to 'Ofsted page'' (#60) from fix/ofsted-link-text 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 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 41s
Reviewed-on: #60
2026-07-18 14:20:09 +00:00
TudorandClaude Fable 5 3e77057567 style(compare): shorten Ofsted link text to 'Ofsted page'
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
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 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The link repeated the full school name ('Watford Grammar School for Boys's
Ofsted page') — long on mobile, and the colour-coded school name already
leads the row. Show just 'Ofsted page →'; keep the full name in aria-label
so screen readers can still tell the per-school links apart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 15:19:41 +01:00
tudor e0d5a9969e Merge pull request 'style(compare): enlarge per-school name label in mobile section cards' (#59) from fix/mobile-cell-name-size 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 49s
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 41s
Reviewed-on: #59
2026-07-18 14:09:02 +00:00
TudorandClaude Fable 5 dc21e80a5e style(compare): enlarge the per-school name label in mobile section cards
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
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 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
The colour-coded school name that leads each row in a mobile measure card
was 0.8rem — a touch small to scan which school a value belongs to. Bump
to 0.9rem (with a matching 10px dot) so it's easier to read. Mobile only;
desktop uses the column header and hides this label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 15:06:18 +01:00
tudor 6d3a203699 Merge pull request 'fix(compare): anchor grade-5 & EBacc rows; restore secondary-button outline' (#58) from fix/secondary-anchors-button-affordance 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 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 40s
Reviewed-on: #58
2026-07-18 07:29:51 +00:00
TudorandClaude Fable 5 b0c5b6bb57 fix(compare): anchor grade-5 & EBacc rows; restore secondary-button outline
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
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 8s
SF1 (expert sign-off): the secondary academics 'Grade 5+ in English &
maths' and 'EBacc entry' rows rendered as bare numbers — the one place
the 'every number anchored against the England average' promise broke.
Both now show the official England anchor (already in the payload) + an
Above/Close/Below verdict chip, matching Attainment 8 (which also gains a
chip for consistency).

Button affordance: globals.css has a duplicate .btn block whose
'border: none' overrode the base '1px solid transparent', so
.btn-secondary/.btn-active's 'border-color' had no width — every outline
button (the modal's '+ Compare', search-result '+ Compare', 'Comparing')
rendered as borderless teal text. Give the outline variants the full
'border' shorthand so the outline renders regardless of the clobbered base.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 08:26:59 +01:00
tudor a1128bd801 Merge pull request 'fix(compare): keep add-school sheet above the mobile keyboard (VisualViewport)' (#57) from fix/modal-keyboard-viewport 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 55s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 42s
Reviewed-on: #57
2026-07-18 07:09:03 +00:00
TudorandClaude Fable 5 2fd997bfe6 fix(compare): keep the add-school sheet above the on-screen keyboard
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
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 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m31s
The bottom-sheet overlay was position:fixed inset:0, sized to the LAYOUT
viewport, so when the mobile keyboard opened it kept full height: the
sheet (anchored to the overlay's bottom) and the lower half of the dim
backdrop sat behind the keyboard, hiding the search input and results.

Track window.visualViewport (height + offsetTop) while the modal is open
and pin the overlay to the visible region, so backdrop and sheet stay
above the keyboard. Size the sheet against the overlay (max-height:100%,
min-height:min(55vh,100%)) instead of vh, and drop the results list's own
inner scroll on mobile so the sheet is a single scroll area.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 08:00:31 +01:00
tudor bdd9bef349 Merge pull request 'fix(compare): repair mobile add-school modal + re-apply stranded chart-height fix' (#56) from fix/mobile-compare-polish 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 51s
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 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 41s
Reviewed-on: #56
2026-07-17 22:11:45 +00:00
TudorandClaude Fable 5 e5f7f4c959 fix(compare): repair the mobile add-school modal
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
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 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 52s
The 'add school' modal passed no title to Modal, so its header held only
the close button — shoved to the far left by justify-content:space-between
in an otherwise-empty bar — while the real title was rendered separately
inside the content. Pass the title to Modal so the header reads title
(left) + close (right), and drop the duplicate in-content heading.

Also: the mobile full-width result button targeted a dead .addButton
selector (the button never had that class), so it rendered inconsistently
— give the button a real module class and full width on mobile, tighten
the stacked cards, and give the bottom sheet a stable min-height so its
empty state isn't a tiny stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 23:02:12 +01:00
TudorandClaude Fable 5 60918da483 fix(compare): give the trend chart its own definite canvas height so the gap note can't squash it
The chart note (and the 2-row mobile chip legend) lived inside the fixed
360px/420px chartBox and competed with the canvas for it, so a longer
caption — e.g. the new KS4 gap note — shrank the mobile canvas to ~178px
and tripped the e2e >220px guard. The canvas now owns a definite height
(280px mobile / 380px desktop) with chips above and the note flowing
below at natural size, so neither can eat the plot. Verified on staging:
patched canvas renders 280px vs the squashed 178px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 22:58:35 +01:00
tudor 284215fbce Merge pull request 'fix(compare): expert should-fixes S1-S4, S6 (S5/S7 closed by verification)' (#55) from fix/expert-signoff-shouldfixes 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 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 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 45s
Reviewed-on: #55
2026-07-17 17:53:16 +00:00
TudorandClaude Fable 5 200a97d0b9 fix(compare): per-school P8 explanation; disadvantaged cohort from the same yearly row
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 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m24s
Review findings: (1) the 'no KS2 baseline (COVID)' explanation was derived
from the basket-wide max year, so a school with an unrelated data gap
borrowed it from a neighbour with 2024/25 data — now judged per school on
its own latest year. (2) The '~N disadvantaged pupils' cohort multiplied
eligible_pupils and disadvantaged_pct resolved independently across years
— now both come from the same yearly row that supplies the displayed
percentage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 17:53:10 +01:00
TudorandClaude Fable 5 f3fa12806b fix(compare): expert should-fixes S1-S4, S6 — banded chips, P8 reason, KS4 gap caption, all-through framing, cohort sizes
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 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m8s
S1: first-choice chip banded (More than half / About 1 in 3 / Over 1 in 4
missed out) so a 44%-offered grammar isn't understated by half.
S2: Progress 8 explains its absence for 2024/25+ cohorts (no KS2 baseline,
COVID) instead of a bare 'No data'.
S3: KS4 trend charts get their own honest gap caption (2019/20-2020/21
unpublished; later years not in our dataset yet); y-axis 'Value'→'Score';
buildCompareChart exposes englandOnlyYears.
S4: all-through schools labelled in chips, rail caption says 'N schools ·
<phase> view' for mixed baskets, whole-school roll no longer judged
against the single-phase median, community section carries an all-ages
caveat.
S6 (spec §8.5): disadvantaged attainment shows the cohort behind it
('of ~50 disadvantaged pupils').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 17:41:12 +01:00
tudor 1004f08daf Merge pull request 'fix(pipeline): annual DAG never builds the official KS4 national mart' (#54) from fix/annual-dag-ks4-national-selector 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 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m10s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 45s
Reviewed-on: #54
2026-07-17 13:28:08 +00:00
TudorandClaude Fable 5 79246edc22 fix(pipeline): build stg_ees_ks4_national in the annual EES DAG
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
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 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 37s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 17s
The DAG's dbt --select list predates the official-KS4-nationals stream,
so the extract loaded raw.ees_ks4_national but the staging model and
fact_ks4_national_averages were never rebuilt — staging kept serving the
old computed means after the DAG run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 14:15:11 +01:00
tudor 64b63b96c8 Merge pull request 'fix(compare): expert sign-off must-fixes M1-M3' (#53) from fix/expert-signoff-mustfixes into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
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 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 43s
Reviewed-on: #53
2026-07-17 12:02:55 +00:00
51 changed files with 1539 additions and 356 deletions
+4 -1
View File
@@ -67,9 +67,12 @@ jobs:
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)" echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
- name: Verify the staging E2E gate passed for this commit - name: Verify the staging E2E gate passed for this commit
# Use the built-in Actions token (GITHUB_TOKEN is the documented name;
# it carries repository read scope), NOT REGISTRY_TOKEN — the registry
# token has no repo scope, so the commit-status API returns 403.
run: | run: |
STATUS_JSON=$(curl -fsS \ STATUS_JSON=$(curl -fsS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status") "https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
echo "$STATUS_JSON" | python3 -c " echo "$STATUS_JSON" | python3 -c "
import json, sys import json, sys
+4
View File
@@ -807,6 +807,10 @@ async def get_la_averages(request: Request):
_KS2_NATIONAL_METRICS = [ _KS2_NATIONAL_METRICS = [
"rwm_expected_pct", "rwm_high_pct", "rwm_expected_pct", "rwm_high_pct",
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct", "reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
# Per-subject higher-standard nationals: reading/maths reach the "higher
# standard" in the tests; writing is teacher-assessed at "greater depth"
# (writing_gd_pct). Needed so each SATs bar compares to its own benchmark.
"reading_high_pct", "writing_gd_pct", "maths_high_pct",
"gps_expected_pct", "gps_high_pct", "science_expected_pct", "gps_expected_pct", "gps_high_pct", "science_expected_pct",
"reading_avg_score", "maths_avg_score", "gps_avg_score", "reading_avg_score", "maths_avg_score", "gps_avg_score",
"reading_progress", "writing_progress", "maths_progress", "reading_progress", "writing_progress", "maths_progress",
+7 -1
View File
@@ -172,6 +172,7 @@ _MAIN_QUERY = text("""
foi.ofsted_grade, foi.ofsted_grade,
foi.ofsted_date, foi.ofsted_date,
foi.ofsted_framework, foi.ofsted_framework,
foi.ofsted_rc_date,
l.local_authority_name AS local_authority, l.local_authority_name AS local_authority,
l.local_authority_code, l.local_authority_code,
l.address_line1 AS address1, l.address_line1 AS address1,
@@ -256,7 +257,12 @@ _MAIN_QUERY = text("""
-- Fall back to the ungraded-inspection grade when no graded grade exists. -- Fall back to the ungraded-inspection grade when no graded grade exists.
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade, COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
inspection_date AS ofsted_date, inspection_date AS ofsted_date,
framework AS ofsted_framework framework AS ofsted_framework,
-- Report-card signal for list/map badges: non-null only when the
-- latest inspection carries report-card grades. framework is the
-- raw event grouping ("Schools - S5"), never "ReportCard", so it
-- can't be used to detect report cards.
rc_inspection_date AS ofsted_rc_date
FROM marts.fact_ofsted_inspection FROM marts.fact_ofsted_inspection
ORDER BY urn, inspection_date DESC NULLS LAST ORDER BY urn, inspection_date DESC NULLS LAST
) foi ON s.urn = foi.urn ) foi ON s.urn = foi.urn
+1
View File
@@ -550,6 +550,7 @@ SCHOOL_COLUMNS = [
"ofsted_grade", "ofsted_grade",
"ofsted_date", "ofsted_date",
"ofsted_framework", "ofsted_framework",
"ofsted_rc_date",
"latitude", "latitude",
"longitude", "longitude",
] ]
@@ -25,6 +25,11 @@ class _Ks2Row:
year = LATEST year = LATEST
rwm_expected_pct = 62.1 rwm_expected_pct = 62.1
gps_expected_pct = 72.0 gps_expected_pct = 72.0
# Per-subject higher-standard nationals — reading/maths reach the higher
# standard, writing is teacher-assessed at greater depth (writing_gd_pct).
reading_high_pct = 29.0
writing_gd_pct = 13.0
maths_high_pct = 24.0
class _Ks4Row: class _Ks4Row:
@@ -84,6 +89,16 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02 assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
def test_per_subject_higher_standard_nationals_are_surfaced(payload):
# The SATs chart compares each bar to its own benchmark, so the per-subject
# higher-standard / greater-depth nationals must reach the payload — not
# only the combined rwm_high_pct.
body = payload(_StubSession)
assert body["primary"]["reading_high_pct"] == 29.0
assert body["primary"]["writing_gd_pct"] == 13.0
assert body["primary"]["maths_high_pct"] == 24.0
def test_ks4_secondary_empty_when_mart_missing(payload): def test_ks4_secondary_empty_when_mart_missing(payload):
# No computed stand-in: the UI labels national figures as official DfE # No computed stand-in: the UI labels national figures as official DfE
# data, so an empty mart must yield an empty secondary series. # data, so an empty mart must yield an empty secondary series.
+119
View File
@@ -80,6 +80,33 @@ test('searching by postcode returns nearby schools', async ({ page }) => {
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
}); });
test('a report-card school shows a Report Card badge in search results, not its old grade', async ({ page }) => {
// List/map badges keyed off ofsted_grade (the carried-forward legacy grade)
// and never reached the report-card branch, so report-card schools were
// labelled by their old grade (e.g. "Outstanding · 2021"). The list now
// carries ofsted_rc_date and the badge treats a report card as winning.
const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card
const res = await page.request.get(`/api/schools?search=Barclay%20Primary&page_size=5`);
expect(res.ok()).toBeTruthy();
const barclay = ((await res.json()).schools ?? []).find(
(s: { urn: number }) => s.urn === RC_URN,
);
// Hard assertions, not test.skip: if the backend stops exposing
// ofsted_rc_date for this report-card school, that IS the regression this
// test exists to catch, so it must fail loudly rather than skip.
expect(barclay, 'Barclay must appear in the search results').toBeTruthy();
expect(
barclay.ofsted_rc_date,
'the list must expose ofsted_rc_date for a report-card school',
).toBeTruthy();
await searchByName(page, 'Barclay Primary');
// The Barclay row must be present…
await expect(page.locator(`a[href*="${RC_URN}"]`).first()).toBeVisible({ timeout: 15_000 });
// …badged as a Report Card, not its carried-forward "Outstanding" grade.
await expect(page.getByText(/Report Card ·/).first()).toBeVisible();
});
test('school detail page renders name and performance data', async ({ page }) => { test('school detail page renders name and performance data', async ({ page }) => {
await searchByName(page, 'primary'); await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first(); const firstSchool = schoolLinks(page).first();
@@ -94,6 +121,86 @@ test('school detail page renders name and performance data', async ({ page }) =>
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
}); });
test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => {
// Detail views detected report cards via `framework`, which the API never
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
// dated to a pre-Nov-2025 inspection. Detection now keys off the report_card
// object and dates it with rc_inspection_date.
const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card
const res = await page.request.get(`/api/schools/${RC_URN}`);
expect(res.ok()).toBeTruthy();
const ofsted = (await res.json()).ofsted;
test.skip(
!ofsted?.report_card || Object.keys(ofsted.report_card).length === 0,
'precondition: chosen URN must currently have a report card',
);
const rcYear = new Date(ofsted.rc_inspection_date).getFullYear();
const legacyYear = new Date(ofsted.inspection_date).getFullYear();
await page.goto(`/school/${RC_URN}`);
const ofstedSection = page.locator('#ofsted');
// Detection fixed: rendered as a Report Card, not a legacy "Ofsted Rating".
await expect(ofstedSection.getByText('Ofsted Report Card')).toBeVisible({ timeout: 15_000 });
// Dating fixed: dated to the report-card inspection, never the legacy one.
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${rcYear}`))).toBeVisible();
if (legacyYear !== rcYear) {
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${legacyYear}`))).toHaveCount(0);
}
});
test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not just one phase', async ({ page }) => {
// All-through schools carry both KS2 and KS4 data in the same yearly rows.
// The detail view used to flip them to isSecondary and render GCSE-only,
// hiding the primary phase. It now renders both phases and labels the school
// "All-through".
const AT_URN = 137306; // Hessle High School and Penshurst Primary — all-through
const res = await page.request.get(`/api/schools/${AT_URN}`);
expect(res.ok()).toBeTruthy();
const detail = await res.json();
const rows: Array<{ rwm_expected_pct: number | null; attainment_8_score: number | null }> =
detail.yearly_data ?? [];
const hasKS2 = rows.some((r) => r.rwm_expected_pct != null);
const hasKS4 = rows.some((r) => r.attainment_8_score != null);
test.skip(
(detail.school_info?.phase ?? '').toLowerCase() !== 'all-through' || !hasKS2 || !hasKS4,
'precondition: chosen URN must currently be all-through with both KS2 and KS4 results',
);
await page.goto(`/school/${AT_URN}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
// Labelled as all-through in the hero meta.
await expect(page.getByText(/All-through/i).first()).toBeVisible();
// The combined results section carries both phases.
const results = page.locator('#results');
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: /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
@@ -331,6 +438,18 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false'); await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
}); });
test('admissions guide renders its key milestones', async ({ page }) => {
// Static content page — assert the guide loads and the load-bearing
// milestones parents rely on are present (dates are statutory, so these
// strings are stable invariants, not data-refresh-sensitive).
await page.goto('/admissions');
await expect(page.getByRole('heading', { name: /School Admissions Guide/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /Primary school admissions/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /Secondary school admissions/i })).toBeVisible();
// National Offer Day is the milestone the whole guide builds toward.
await expect(page.getByText(/National Offer Day/i).first()).toBeVisible();
});
test('rankings page loads a populated table', async ({ page }) => { test('rankings page loads a populated table', async ({ page }) => {
await page.goto('/rankings'); await page.goto('/rankings');
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible(); await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
@@ -0,0 +1,65 @@
/**
* Secondary academics: every headline number carries its England anchor and a
* verdict chip (expert sign-off SF1 — the grade-5 and EBacc rows previously
* rendered as bare numbers, breaking the "anchored against England" promise).
*/
import { render, screen, within } from '@testing-library/react';
import { CompareAcademics } from '@/components/compare/CompareAcademics';
import type { ComparisonData, NationalAverages, School } from '@/lib/types';
function school(urn: number, name: string): School {
return { urn, school_name: name, attainment_8_score: 58.7 } as School;
}
function data(urn: number): ComparisonData {
return {
school_info: school(urn, 'Test High'),
yearly_data: [
{
year: 202425,
attainment_8_score: 58.7,
english_maths_strong_pass_pct: 30,
ebacc_entry_pct: 10,
},
] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
const NATIONAL: NationalAverages = {
year: 202425,
primary: {},
secondary: {
attainment_8_score: 46.0,
english_maths_strong_pass_pct: 45.4,
ebacc_entry_pct: 40.5,
},
by_year: [],
};
test('grade-5 and EBacc rows show the England anchor and a Below chip when under it', () => {
const s = school(137086, 'Bishop Stopford School');
render(
<CompareAcademics
schools={[s]}
data={{ '137086': data(137086) }}
nationalAverages={NATIONAL}
isSecondary
/>,
);
// The official anchors appear (45.4% and 40.5%), not just the school numbers.
expect(screen.getByText(/England average 45%/)).toBeInTheDocument();
expect(screen.getByText(/England average 41%/)).toBeInTheDocument();
// 30% grade-5 and 10% EBacc are both well below their anchors → Below chips.
// Attainment 8 (58.7 vs 46.0) is above → at least one "Above" chip too.
expect(screen.getAllByText(/Below England average/).length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText(/Above England average/).length).toBeGreaterThanOrEqual(1);
});
@@ -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();
});
});
@@ -73,6 +73,15 @@ describe('buildCompareChart', () => {
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7); expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
}); });
it('lists England-only years so the component can caption dashed-only stretches', () => {
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
202122: 58.7,
});
expect(chart.englandOnlyYears).toEqual([202122]);
const none = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
expect(none.englandOnlyYears).toEqual([]);
});
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => { it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', { const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
202122: 58.7, 202122: 58.7,
@@ -177,6 +177,16 @@ describe('summariseAdmissions', () => {
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' }); expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
}); });
it('60% → "About 1 in 3 first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 60 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'About 1 in 3 first choices missed out' });
});
it('44% (selective-scale demand) → "More than half of first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 43.69 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'More than half of first choices missed out' });
});
it('100% → "All first choices offered"', () => { it('100% → "All first choices offered"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 })); const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' }); expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
+85 -3
View File
@@ -9,6 +9,7 @@ import {
isValidPostcode, isValidPostcode,
debounce, debounce,
buildOfstedListBadge, buildOfstedListBadge,
ofstedLegacyAreas,
metricKind, metricKind,
shortName, shortName,
computeYBounds, computeYBounds,
@@ -111,6 +112,35 @@ describe('debounce', () => {
}); });
}); });
describe('ofstedLegacyAreas', () => {
it('returns only real grades (1-4) and drops sentinel/null values', () => {
const areas = ofstedLegacyAreas({
quality_of_education: 1,
behaviour_attitudes: 2,
personal_development: null,
leadership_management: 9, // sentinel "not applicable" — must be dropped
early_years_provision: 9, // secondary school — not applicable
sixth_form_provision: 2,
});
expect(areas.map((a) => a.label)).toEqual([
'Quality of Teaching',
'Behaviour in School',
'Sixth Form',
]);
expect(areas.every((a) => a.value >= 1 && a.value <= 4)).toBe(true);
});
it('includes the sixth-form judgement where present (was previously dropped)', () => {
const areas = ofstedLegacyAreas({ sixth_form_provision: 1 });
expect(areas).toEqual([{ label: 'Sixth Form', value: 1 }]);
});
it('includes Early Years for a primary school', () => {
const areas = ofstedLegacyAreas({ quality_of_education: 2, early_years_provision: 1 });
expect(areas.map((a) => a.label)).toContain('Early Years (Reception)');
});
});
describe('buildOfstedListBadge', () => { describe('buildOfstedListBadge', () => {
it('returns grade word + year for OEIF Outstanding', () => { it('returns grade word + year for OEIF Outstanding', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' }); const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
@@ -130,9 +160,23 @@ describe('buildOfstedListBadge', () => {
expect(badge.cssClass).toBe('ofsted2'); expect(badge.cssClass).toBe('ofsted2');
}); });
it('returns Report Card badge when framework is ReportCard', () => { it('returns a Report Card badge when ofsted_rc_date is present', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2025-11-01', ofsted_framework: 'ReportCard' }); const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' });
expect(badge.label).toBe('Report Card · 2025'); expect(badge.label).toBe('Report Card · 2026');
expect(badge.cssClass).toBe('ofstedRc');
});
it('a report card wins over a carried-forward legacy grade', () => {
// The production bug: a report-card school (e.g. Barclay) also carries a
// carried-forward legacy grade (ofsted_grade), which used to win and label
// the pin "Outstanding · 2021" instead of "Report Card · 2026".
const badge = buildOfstedListBadge({
ofsted_grade: 1,
ofsted_date: '2021-10-07',
ofsted_framework: 'Schools - S5',
ofsted_rc_date: '2026-02-03',
});
expect(badge.label).toBe('Report Card · 2026');
expect(badge.cssClass).toBe('ofstedRc'); expect(badge.cssClass).toBe('ofstedRc');
}); });
@@ -225,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', () => { 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');
+8 -3
View File
@@ -183,10 +183,15 @@ body {
} }
/* Secondary: teal outline — supporting actions (+ Compare) */ /* Secondary: teal outline — supporting actions (+ Compare) */
/* NOTE: a duplicate `.btn` block further down this file sets `border: none`,
which wins over the base `.btn`'s `1px solid transparent`. The outline
variants below therefore declare the full `border` shorthand explicitly so
they don't depend on the base border-width — otherwise `border-color` alone
has no width and the outline never renders (buttons read as plain text). */
.btn-secondary { .btn-secondary {
background: transparent; background: transparent;
color: var(--accent-teal); color: var(--accent-teal);
border-color: var(--accent-teal); border: 1px solid var(--accent-teal);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--accent-teal-bg); background: var(--accent-teal-bg);
@@ -196,7 +201,7 @@ body {
.btn-tertiary { .btn-tertiary {
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-secondary); color: var(--text-secondary);
border-color: var(--border-color); border: 1px solid var(--border-color);
} }
.btn-tertiary:hover:not(:disabled) { .btn-tertiary:hover:not(:disabled) {
background: var(--border-color); background: var(--border-color);
@@ -207,7 +212,7 @@ body {
.btn-active { .btn-active {
background: var(--accent-teal-bg); background: var(--accent-teal-bg);
color: var(--accent-teal); color: var(--accent-teal);
border-color: var(--accent-teal); border: 1px solid var(--accent-teal);
} }
.btn-active:hover:not(:disabled) { .btn-active:hover:not(:disabled) {
background: transparent; background: transparent;
+18 -7
View File
@@ -10,6 +10,7 @@ import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js'; import { ChartOptions } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import { formatAcademicYear } from '@/lib/utils'; import { formatAcademicYear } from '@/lib/utils';
import { fillAcademicYears } from '@/lib/compareChartData';
import type { SchoolAdmissions } from '@/lib/types'; import type { SchoolAdmissions } from '@/lib/types';
import styles from './AdmissionsTrendChart.module.css'; import styles from './AdmissionsTrendChart.module.css';
@@ -17,13 +18,22 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
const pts = history.filter((h) => h.first_preference_offer_pct != null); const pts = history.filter((h) => h.first_preference_offer_pct != null);
if (pts.length < 2) return null; if (pts.length < 2) return null;
const labels = pts.map((p) => formatAcademicYear(p.year)); // Gap-honest axis: every academic year between the first and last data point
const values = pts.map((p) => p.first_preference_offer_pct as number); // appears, so a missing admissions year renders as a real gap (spanGaps:false)
const lastIdx = pts.length - 1; // rather than compressing time between distant years.
const axisYears = fillAcademicYears(pts.map((p) => p.year));
const byYear = new Map(pts.map((p) => [p.year, p.first_preference_offer_pct as number]));
const labels = axisYears.map(formatAcademicYear);
const values: (number | null)[] = axisYears.map((y) => byYear.get(y) ?? null);
const present = values
.map((v, i) => (v != null ? i : -1))
.filter((i) => i >= 0);
const lastIdx = present[present.length - 1];
// Auto-scale with headroom so variation is visible, clamped to 0100. // Auto-scale with headroom so variation is visible, clamped to 0100.
const lo = Math.min(...values); const numeric = values.filter((v): v is number => v != null);
const hi = Math.max(...values); const lo = Math.min(...numeric);
const hi = Math.max(...numeric);
const padded = Math.max(5, Math.round((hi - lo) * 0.25)); const padded = Math.max(5, Math.round((hi - lo) * 0.25));
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5); const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5); const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
@@ -69,15 +79,16 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
label: 'First-choice offer rate', label: 'First-choice offer rate',
data: values, data: values,
clip: false as const, clip: false as const,
spanGaps: false,
borderColor: '#e07256', borderColor: '#e07256',
backgroundColor: 'rgba(224,114,86,0.10)', backgroundColor: 'rgba(224,114,86,0.10)',
borderWidth: 2.5, borderWidth: 2.5,
tension: 0.3, tension: 0.3,
fill: true, fill: true,
pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)), pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
pointBackgroundColor: '#e07256', pointBackgroundColor: '#e07256',
pointBorderColor: '#fff', pointBorderColor: '#fff',
pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)), pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
pointHoverRadius: 6, pointHoverRadius: 6,
}, },
], ],
@@ -442,6 +442,58 @@
line-height: 1; line-height: 1;
} }
/* ─── Easy-to-miss callouts ──────────────────────────── */
.calloutGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.7rem;
}
.callout {
display: flex;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
border: 1px solid rgba(201, 162, 39, 0.28);
border-left: 3px solid var(--accent-gold, #c9a227);
border-radius: 8px;
}
.calloutIcon {
flex-shrink: 0;
width: 1.05rem;
height: 1.05rem;
margin-top: 0.1rem;
border-radius: 50%;
background: var(--accent-gold, #c9a227);
color: #fff;
font-size: 0.72rem;
font-weight: 800;
line-height: 1.05rem;
text-align: center;
}
.calloutText {
min-width: 0;
}
.calloutTitle {
font-size: 0.82rem;
font-weight: 700;
color: var(--accent-gold-text, #7a6800);
line-height: 1.3;
margin-bottom: 0.15rem;
}
.calloutBody {
font-size: 0.82rem;
color: var(--text-secondary, #5c564d);
line-height: 1.5;
margin: 0;
}
/* ─── Tips ───────────────────────────────────────────── */ /* ─── Tips ───────────────────────────────────────────── */
.tips { .tips {
+48 -3
View File
@@ -54,6 +54,9 @@ interface Step {
title: string; title: string;
body: string; body: string;
highlight?: 'deadline' | 'offer'; highlight?: 'deadline' | 'offer';
/* Easy-to-miss, deadline-sensitive warnings surfaced as callout boxes
rather than buried in the body prose. */
callouts?: { title: string; body: string }[];
/* How SchoolCompare helps at this stage of the journey */ /* How SchoolCompare helps at this stage of the journey */
tool?: { label: string; href: string }; tool?: { label: string; href: string };
} }
@@ -62,6 +65,16 @@ const SECONDARY_STEPS: Step[] = [
{ {
title: 'Check entry criteria', title: 'Check entry criteria',
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.', body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
callouts: [
{
title: 'Selective schools register earlier',
body: 'Grammar and other selective schools need separate entrance-test registration months earlier — often by September, well before the 31 October deadline.',
},
{
title: 'Faith schools may need an extra form',
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, on top of the council application.',
},
],
tool: { label: 'Find schools & view their admissions history', href: '/' }, tool: { label: 'Find schools & view their admissions history', href: '/' },
}, },
{ {
@@ -73,14 +86,14 @@ const SECONDARY_STEPS: Step[] = [
{ {
date: '31 October', date: '31 October',
title: 'Application deadline', title: 'Application deadline',
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally list schools in the genuine order you want them, not strategically.', body: 'Submit your ranked list of schools — most councils allow three to six preferences (London boroughs typically six). Councils treat all preferences equally, so list schools in the genuine order you want them, not strategically.',
highlight: 'deadline', highlight: 'deadline',
tool: { label: 'Compare performance to order your preferences', href: '/rankings' }, tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
}, },
{ {
date: '1 March', date: '1 March',
title: 'National Offer Day', title: 'National Offer Day',
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.', body: 'Your allocated school appears on the council\'s online admissions portal and is sent by email. Release times are set by each council — often from late afternoon, though some publish overnight — so check your council\'s page rather than refreshing at midnight.',
highlight: 'offer', highlight: 'offer',
tool: { label: 'Look up your allocated school', href: '/' }, tool: { label: 'Look up your allocated school', href: '/' },
}, },
@@ -101,6 +114,12 @@ const PRIMARY_STEPS: Step[] = [
{ {
title: 'Research entry criteria', title: 'Research entry criteria',
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.', body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
callouts: [
{
title: 'Faith schools may need an extra form',
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, in addition to the council application.',
},
],
tool: { label: 'Find schools & view their admissions history', href: '/' }, tool: { label: 'Find schools & view their admissions history', href: '/' },
}, },
{ {
@@ -144,7 +163,7 @@ interface Tip {
const TIPS: Tip[] = [ const TIPS: Tip[] = [
{ {
heading: 'Equal preference rule', heading: 'Equal preference rule',
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually want them.', body: 'Councils consider every school you list before making any offer, then give you the highest-ranked one you qualify for. Your order only decides which qualifying school you get — you can\'t game it, so list schools in the order you genuinely want them.',
}, },
{ {
heading: 'Late applications go to the back', heading: 'Late applications go to the back',
@@ -307,6 +326,19 @@ export function AdmissionsView() {
{step.date && <div className={styles.stepDate}>{step.date}</div>} {step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div> <div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p> <p className={styles.stepBody}>{step.body}</p>
{step.callouts && step.callouts.length > 0 && (
<div className={styles.calloutGroup}>
{step.callouts.map((c) => (
<div key={c.title} className={styles.callout}>
<span className={styles.calloutIcon} aria-hidden="true">!</span>
<div className={styles.calloutText}>
<div className={styles.calloutTitle}>{c.title}</div>
<p className={styles.calloutBody}>{c.body}</p>
</div>
</div>
))}
</div>
)}
{step.tool && ( {step.tool && (
<a <a
href={step.tool.href} href={step.tool.href}
@@ -355,6 +387,19 @@ export function AdmissionsView() {
{step.date && <div className={styles.stepDate}>{step.date}</div>} {step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div> <div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p> <p className={styles.stepBody}>{step.body}</p>
{step.callouts && step.callouts.length > 0 && (
<div className={styles.calloutGroup}>
{step.callouts.map((c) => (
<div key={c.title} className={styles.callout}>
<span className={styles.calloutIcon} aria-hidden="true">!</span>
<div className={styles.calloutText}>
<div className={styles.calloutTitle}>{c.title}</div>
<p className={styles.calloutBody}>{c.body}</p>
</div>
</div>
))}
</div>
)}
{step.tool && ( {step.tool && (
<a <a
href={step.tool.href} href={step.tool.href}
@@ -1,16 +1,23 @@
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the /* Chart wrapper: chips (mobile) above, then the canvas, then the gap note.
parent .chartContainer, whose fixed height drives Chart.js sizing via The canvas has its OWN definite height (Chart.js needs one for
maintainAspectRatio: false. */ maintainAspectRatio: false); the chips and the note flow at their natural
size around it rather than competing with it for a fixed outer height —
so a longer note (e.g. the KS4 gap caption) or a two-row chip legend can
never squash the chart. */
.wrapper { .wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
} }
.canvasBox { .canvasBox {
position: relative; position: relative;
flex: 1 1 auto; height: 380px;
min-height: 0; }
@media (max-width: 640px) {
.canvasBox {
height: 280px;
}
} }
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps /* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
+17 -3
View File
@@ -38,13 +38,15 @@ interface ComparisonChartProps {
/** Official England figure per academic year for this metric — renders a /** Official England figure per academic year for this metric — renders a
* dashed grey reference line when provided. */ * dashed grey reference line when provided. */
nationalByYear?: Record<number, number | null | undefined>; nationalByYear?: Record<number, number | null | undefined>;
/** KS4 metrics get a different (honest) gap caption than KS2. */
isSecondary?: boolean;
} }
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so // One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone. // converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star']; const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) { export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null); const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
@@ -168,7 +170,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
display: true, display: true,
title: { title: {
display: !isMobile, display: !isMobile,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value', text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
@@ -240,11 +242,23 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
<div className={styles.canvasBox}> <div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} /> <Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div> </div>
{built.showUnpublished202122Note && ( {isSecondary && built.englandOnlyYears.length > 0 ? (
// KS4's honest story differs from KS2's: 2019/202020/21 school-level
// GCSE results weren't published (COVID grading); later years WERE
// published by DfE but aren't in our dataset yet.
<p className={styles.chartNote}>
School-level GCSE figures for 2019/20 and 2020/21 weren&apos;t published (COVID
grading), and more recent years aren&apos;t in our dataset yet where lines break the
England average is shown where available.
</p>
) : (
!isSecondary &&
built.showUnpublished202122Note && (
<p className={styles.chartNote}> <p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish
school-level figures for 2021/22 the England average is shown for that year. school-level figures for 2021/22 the England average is shown for that year.
</p> </p>
)
)} )}
</div> </div>
); );
+16 -4
View File
@@ -365,12 +365,18 @@ export function ComparisonView({
style={{ '--school-count': activeSchools.length } as CSSProperties} style={{ '--school-count': activeSchools.length } as CSSProperties}
aria-label="Schools in this comparison" aria-label="Schools in this comparison"
> >
{/* Fills the 200px label rail on desktop (hidden on mobile). */} {/* Fills the 200px label rail on desktop (hidden on mobile).
All-through schools must not be miscounted as "primary
schools"/"secondary schools" — mixed baskets get "· primary
view" phrasing instead. */}
<div className={styles.barCaption}> <div className={styles.barCaption}>
<span className={styles.barCaptionEyebrow}>Comparing</span> <span className={styles.barCaptionEyebrow}>Comparing</span>
<span className={styles.barCaptionCount}> <span className={styles.barCaptionCount}>
{activeSchools.length} {comparePhase} school {activeSchools.every((sch) =>
{activeSchools.length === 1 ? '' : 's'} sch.phase?.toLowerCase().includes(comparePhase),
)
? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
: `${activeSchools.length} schools · ${comparePhase} view`}
</span> </span>
</div> </div>
{activeSchools.map((school, index) => ( {activeSchools.map((school, index) => (
@@ -390,7 +396,13 @@ export function ComparisonView({
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span> <span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
</a> </a>
<span className={styles.chipMeta}> <span className={styles.chipMeta}>
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')} {[
/all.?through/i.test(school.phase ?? '') ? 'All-through' : null,
school.local_authority,
school.school_type,
]
.filter(Boolean)
.join(' · ')}
</span> </span>
</span> </span>
<button <button
+5 -5
View File
@@ -13,17 +13,17 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
<div className={styles.editorialGrid}> <div className={styles.editorialGrid}>
<div className={styles.editorialText}> <div className={styles.editorialText}>
<div className={styles.editorialKicker}>About school data</div> <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> <p>
School performance data in England is rich but fragmented. The Department for Education publishes School performance data in England is rich but fragmented. The Department for Education and Ofsted
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
demographics each in its own table, each with its own jargon. and demographics each in its own table, each with its own jargon.
</p> </p>
<p> <p>
SchoolCompare brings it all into one place. Every school page shows performance against the national 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 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 parents, governors, journalists, and anyone who wants to understand a school without reading a
40-page inspection report. full inspection report.
</p> </p>
</div> </div>
<div className={styles.factbox}> <div className={styles.factbox}>
+66 -14
View File
@@ -720,10 +720,13 @@
.miniRowHead { .miniRowHead {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 0.48rem; align-items: baseline;
gap: 0.2rem;
font-size: 0.44rem;
color: var(--text-muted, #6d685f); color: var(--text-muted, #6d685f);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.01em;
white-space: nowrap;
} }
.miniRowHead strong { .miniRowHead strong {
@@ -869,28 +872,77 @@
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
} }
.ofstedBadge { .ofstedFramework {
align-self: flex-start; align-self: flex-start;
padding: 0.2rem 0.55rem; padding: 0.15rem 0.5rem;
border-radius: 4px; border-radius: 4px;
background: rgba(45, 125, 125, 0.12); background: rgba(90, 58, 110, 0.12);
color: var(--accent-teal, #2d7d7d); color: #5a3a6e;
font-size: 0.55rem; font-size: 0.5rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-transform: uppercase;
} }
.ofstedVerdict { .rcList {
font-family: var(--font-playfair), 'Playfair Display', serif; display: flex;
font-size: 1.05rem; 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; font-weight: 700;
line-height: 1.2; letter-spacing: 0.03em;
color: var(--text-primary, #1a1612); white-space: nowrap;
flex-shrink: 0;
} }
.ofstedVerdict em { .rcChipGood {
background: rgba(45, 125, 125, 0.12);
color: var(--accent-teal, #2d7d7d); 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 { .ofstedMeta {
+1 -1
View File
@@ -261,7 +261,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
<div className={styles.heroSection}> <div className={styles.heroSection}>
<span className={styles.heroEyebrow}> <span className={styles.heroEyebrow}>
<span className={styles.heroEyebrowDot} aria-hidden="true" /> <span className={styles.heroEyebrowDot} aria-hidden="true" />
Updated with 2026/2027 admissions results Updated for the 2026/27 admissions round
</span> </span>
<h1 className={styles.heroTitle}> <h1 className={styles.heroTitle}>
Every school in England, <em className={styles.heroEmph}>compared.</em> Every school in England, <em className={styles.heroEmph}>compared.</em>
+37 -15
View File
@@ -5,15 +5,26 @@
import styles from './HomeView.module.css'; import styles from './HomeView.module.css';
export function HowItWorksSection() { 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 = [ const miniCascade = [
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 }, { subj: 'Reading', exp: 96, exc: 44, nat: 75, excLabel: 'Higher std' },
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 }, { subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 }, { subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
]; ];
const compareRows = [ const compareRows = [
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true }, { 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: '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 ( return (
@@ -29,7 +40,7 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseBlock}> <div className={styles.hiwPhaseBlock}>
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div> <div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
<div className={styles.miniCascade}> <div className={styles.miniCascade}>
{miniCascade.map(({ subj, exp, exc, nat }) => ( {miniCascade.map(({ subj, exp, exc, nat, excLabel }) => (
<div key={subj} className={styles.miniCascadeCol}> <div key={subj} className={styles.miniCascadeCol}>
<div className={styles.miniSubj}>{subj}</div> <div className={styles.miniSubj}>{subj}</div>
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></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.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} /> <div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
</div> </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.miniTrack}>
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} /> <div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
</div> </div>
@@ -49,15 +60,15 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div> <div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
<div className={styles.att8Row}> <div className={styles.att8Row}>
<div className={styles.att8BarWrap}> <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.att8Track}>
<div className={styles.att8Fill} style={{ width: '62%' }} /> <div className={styles.att8Fill} style={{ width: '54.8%' }} />
<div className={styles.att8NatLine} style={{ left: '50%' }} /> <div className={styles.att8NatLine} style={{ left: '45.9%' }} />
</div> </div>
</div> </div>
<div className={styles.att8Score}> <div className={styles.att8Score}>
<div className={styles.att8Value}>62.4</div> <div className={styles.att8Value}>54.8</div>
<div className={styles.att8Delta}>+12.2 vs national</div> <div className={styles.att8Delta}>+8.9 vs national</div>
</div> </div>
</div> </div>
</div> </div>
@@ -77,15 +88,26 @@ export function HowItWorksSection() {
<span className={styles.ofstedBullet} /> <span className={styles.ofstedBullet} />
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span> <span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
</div> </div>
<span className={styles.ofstedBadge}>OUTSTANDING</span> <span className={styles.ofstedFramework}>Report card · five-point scale</span>
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div> <div className={styles.rcList}>
<div className={styles.ofstedMeta}>Full inspection · March 2024</div> {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> </div>
<div className={styles.hiwCardBody}> <div className={styles.hiwCardBody}>
<div className={styles.hiwStep}>Judgement</div> <div className={styles.hiwStep}>Judgement</div>
<div className={styles.hiwTitle}>Ofsted at a glance</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>
</div> </div>
+7 -3
View File
@@ -43,6 +43,13 @@ interface PopupBadge {
} }
function buildPopupBadge(school: School): PopupBadge { function buildPopupBadge(school: School): PopupBadge {
// A report card wins over any carried-forward legacy grade — its presence is
// signalled by ofsted_rc_date (the list has no full report_card object, and
// ofsted_framework is the raw event grouping, never "ReportCard").
if (school.ofsted_rc_date) {
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
return { label: `Report Card · ${rcYear}`, style: 'background:#5a3a6e;color:#fff' };
}
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null; const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
const yearStr = year ? ` · ${year}` : ''; const yearStr = year ? ` · ${year}` : '';
if (school.ofsted_grade) { if (school.ofsted_grade) {
@@ -55,9 +62,6 @@ function buildPopupBadge(school: School): PopupBadge {
}; };
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] }; return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
} }
if (school.ofsted_framework === 'ReportCard') {
return { label: `Report Card${yearStr}`, style: 'background:#5a3a6e;color:#fff' };
}
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' }; return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
} }
+7 -1
View File
@@ -123,7 +123,13 @@
.modal { .modal {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
max-height: 95vh; /* Bottom sheet sized against the overlay (which tracks the visual
viewport), NOT vh: when the keyboard is open the overlay is short, so
max-height:100% keeps the whole sheet — input and results — above the
keyboard. min-height gives a comfortable default without a tiny stub,
but is capped at 100% so it never exceeds the visible area. */
min-height: min(55vh, 100%);
max-height: 100%;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
animation: slideUp 0.3s ease; animation: slideUp 0.3s ease;
+30 -2
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useEffect, useCallback } from 'react'; import { useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import styles from './Modal.module.css'; import styles from './Modal.module.css';
@@ -18,6 +18,8 @@ interface ModalProps {
} }
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) { export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const handleEscape = useCallback((e: KeyboardEvent) => { const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
onClose(); onClose();
@@ -39,6 +41,32 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
}; };
}, [isOpen, handleEscape]); }, [isOpen, handleEscape]);
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
// the on-screen keyboard shrinks the visual viewport but not the layout one,
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
// bottom-anchored sheet (and the dim backdrop's lower half) hidden behind
// the keyboard. Tracking visualViewport.height/offsetTop keeps the whole
// overlay — backdrop and sheet — inside the visible area, above the keyboard.
useEffect(() => {
if (!isOpen) return;
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
const el = overlayRef.current;
if (!vv || !el) return;
const sync = () => {
el.style.top = `${vv.offsetTop}px`;
el.style.height = `${vv.height}px`;
el.style.bottom = 'auto';
};
sync();
vv.addEventListener('resize', sync);
vv.addEventListener('scroll', sync);
return () => {
vv.removeEventListener('resize', sync);
vv.removeEventListener('scroll', sync);
};
}, [isOpen]);
if (!isOpen || typeof window === 'undefined') return null; if (!isOpen || typeof window === 'undefined') return null;
const handleOverlayClick = (e: React.MouseEvent) => { const handleOverlayClick = (e: React.MouseEvent) => {
@@ -48,7 +76,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
}; };
return createPortal( return createPortal(
<div className={styles.overlay} onClick={handleOverlayClick}> <div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
<div className={`${styles.modal} ${styles[size]}`}> <div className={`${styles.modal} ${styles[size]}`}>
<div className={styles.header}> <div className={styles.header}>
{title && <h2 className={styles.title}>{title}</h2>} {title && <h2 className={styles.title}>{title}</h2>}
+44 -23
View File
@@ -16,6 +16,7 @@ import { ChartOptions, ChartDataset } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import type { SchoolResult } from '@/lib/types'; import type { SchoolResult } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils'; import { formatAcademicYear } from '@/lib/utils';
import { fillAcademicYears } from '@/lib/compareChartData';
import { useIsMobile } from '@/hooks/useIsMobile'; import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics'; import { track } from '@/lib/analytics';
import styles from './PerformanceChart.module.css'; import styles from './PerformanceChart.module.css';
@@ -35,8 +36,6 @@ interface PerformanceChartProps {
nationalByYear?: NationalByYear[]; nationalByYear?: NationalByYear[];
} }
const COVID_YEARS = new Set([201920, 202021]);
// Mobile chip definitions: which datasets render when each chip is active. // Mobile chip definitions: which datasets render when each chip is active.
// `series` keys reference the dataset labels so we can filter cleanly. // `series` keys reference the dataset labels so we can filter cleanly.
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8'; type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
@@ -67,21 +66,33 @@ export function PerformanceChart({
nationalByYear, nationalByYear,
}: PerformanceChartProps) { }: PerformanceChartProps) {
const sortedData = [...data].sort((a, b) => a.year - b.year); const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => formatAcademicYear(d.year));
// Gap-honest year axis: every academic year between the first and last data
// point appears, so cancelled/unpublished years (2019/20, 2020/21, and — for
// KS2 — 2021/22) render as real gaps rather than compressed time. School
// lines never bridge these gaps (spanGaps:false below).
const axisYears = fillAcademicYears(sortedData.map(d => d.year));
const byYear = new Map(sortedData.map(d => [d.year, d]));
const col = (key: keyof SchoolResult): (number | null)[] =>
axisYears.map(y => {
const v = byYear.get(y)?.[key];
return typeof v === 'number' ? v : null;
});
const years = axisYears.map(formatAcademicYear);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
// ── Build per-year national averages ───────────────────────────────── // ── Build per-year national averages (aligned to the filled axis) ────
const natRefRwm: (number | null)[] = sortedData.map(d => { const natRefRwm: (number | null)[] = axisYears.map(y => {
if (nationalByYear) { if (nationalByYear) {
const match = nationalByYear.find(n => n.year === d.year); const match = nationalByYear.find(n => n.year === y);
return match?.primary?.rwm_expected_pct ?? null; return match?.primary?.rwm_expected_pct ?? null;
} }
return nationalRwmAvg ?? null; return nationalRwmAvg ?? null;
}); });
const natRefAtt8: (number | null)[] = sortedData.map(d => { const natRefAtt8: (number | null)[] = axisYears.map(y => {
if (nationalByYear) { if (nationalByYear) {
const match = nationalByYear.find(n => n.year === d.year); const match = nationalByYear.find(n => n.year === y);
return match?.secondary?.attainment_8_score ?? null; return match?.secondary?.attainment_8_score ?? null;
} }
return nationalAtt8Avg ?? null; return nationalAtt8Avg ?? null;
@@ -107,10 +118,11 @@ export function PerformanceChart({
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`; return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
})(); })();
const hasCovidGap = isSecondary // A gap year is any filled axis year the school has no results row for —
? false // exactly the cancelled/unpublished years the fill introduced. Drives the
: COVID_YEARS.size > 0 && // honest note below (KS2 and KS4 have different gap stories).
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y)); const gapYears = axisYears.filter(y => !byYear.has(y));
const hasGap = gapYears.length > 0;
// ── Datasets (full set; mobile filters them via the active chip) ───── // ── Datasets (full set; mobile filters them via the active chip) ─────
const refLineStyle = { const refLineStyle = {
@@ -120,13 +132,14 @@ export function PerformanceChart({
borderDash: [6, 4] as number[], borderDash: [6, 4] as number[],
pointRadius: 0, pointRadius: 0,
tension: 0, tension: 0,
spanGaps: false as const,
order: 10, order: 10,
}; };
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [ const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
{ {
label: 'Attainment 8', label: 'Attainment 8',
data: sortedData.map(d => d.attainment_8_score), data: col('attainment_8_score'),
borderColor: '#2d7d7d', borderColor: '#2d7d7d',
backgroundColor: 'rgba(45,125,125,0.08)', backgroundColor: 'rgba(45,125,125,0.08)',
borderWidth: 2.5, borderWidth: 2.5,
@@ -137,7 +150,7 @@ export function PerformanceChart({
}, },
{ {
label: 'English & Maths Grade 4+', label: 'English & Maths Grade 4+',
data: sortedData.map(d => d.english_maths_standard_pass_pct), data: col('english_maths_standard_pass_pct'),
borderColor: '#c9a227', borderColor: '#c9a227',
backgroundColor: 'rgba(201,162,39,0.08)', backgroundColor: 'rgba(201,162,39,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -147,7 +160,7 @@ export function PerformanceChart({
}, },
{ {
label: 'Progress 8', label: 'Progress 8',
data: sortedData.map(d => d.progress_8_score), data: col('progress_8_score'),
borderColor: 'rgb(139,92,246)', borderColor: 'rgb(139,92,246)',
backgroundColor: 'rgba(139,92,246,0.08)', backgroundColor: 'rgba(139,92,246,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -165,7 +178,7 @@ export function PerformanceChart({
] : [ ] : [
{ {
label: 'Reading, Writing & Maths expected %', label: 'Reading, Writing & Maths expected %',
data: sortedData.map(d => d.rwm_expected_pct), data: col('rwm_expected_pct'),
borderColor: '#2d7d7d', borderColor: '#2d7d7d',
backgroundColor: 'rgba(45,125,125,0.08)', backgroundColor: 'rgba(45,125,125,0.08)',
borderWidth: 2.5, borderWidth: 2.5,
@@ -176,7 +189,7 @@ export function PerformanceChart({
}, },
{ {
label: 'Exceeding expected level', label: 'Exceeding expected level',
data: sortedData.map(d => d.rwm_high_pct), data: col('rwm_high_pct'),
borderColor: '#c9a227', borderColor: '#c9a227',
backgroundColor: 'rgba(201,162,39,0.08)', backgroundColor: 'rgba(201,162,39,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -192,7 +205,7 @@ export function PerformanceChart({
} as ChartDataset<'line'>] : []), } as ChartDataset<'line'>] : []),
{ {
label: 'Reading progress', label: 'Reading progress',
data: sortedData.map(d => d.reading_progress), data: col('reading_progress'),
borderColor: 'rgb(59,130,246)', borderColor: 'rgb(59,130,246)',
backgroundColor: 'rgba(59,130,246,0.08)', backgroundColor: 'rgba(59,130,246,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -203,7 +216,7 @@ export function PerformanceChart({
}, },
{ {
label: 'Writing progress', label: 'Writing progress',
data: sortedData.map(d => d.writing_progress), data: col('writing_progress'),
borderColor: 'rgb(139,92,246)', borderColor: 'rgb(139,92,246)',
backgroundColor: 'rgba(139,92,246,0.08)', backgroundColor: 'rgba(139,92,246,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -214,7 +227,7 @@ export function PerformanceChart({
}, },
{ {
label: 'Maths progress', label: 'Maths progress',
data: sortedData.map(d => d.maths_progress), data: col('maths_progress'),
borderColor: 'rgb(236,72,153)', borderColor: 'rgb(236,72,153)',
backgroundColor: 'rgba(236,72,153,0.08)', backgroundColor: 'rgba(236,72,153,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -275,6 +288,8 @@ export function PerformanceChart({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
// Never bridge missing years — cancelled/unpublished years are real gaps.
datasets: { line: { spanGaps: false } },
plugins: { plugins: {
legend: { legend: {
position: 'top', position: 'top',
@@ -326,6 +341,7 @@ export function PerformanceChart({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
datasets: { line: { spanGaps: false } },
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: false }, title: { display: false },
@@ -353,7 +369,10 @@ export function PerformanceChart({
}, },
x: { x: {
grid: { display: false }, grid: { display: false },
ticks: { font: { size: 10 }, autoSkip: false }, // With the gap-honest axis (more year labels) autoSkip keeps the
// phone axis readable; the broken line still shows where a year is
// missing even when its tick label is skipped.
ticks: { font: { size: 10 }, autoSkip: true, maxTicksLimit: 5, maxRotation: 0 },
}, },
}, },
}; };
@@ -416,9 +435,11 @@ export function PerformanceChart({
</div> </div>
)} )}
{hasCovidGap && ( {hasGap && (
<p className={styles.covidNote}> <p className={styles.covidNote}>
* No data for 2019/20 or 2020/21 national assessments were cancelled due to COVID-19. {isSecondary
? "School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID grading) where the line breaks — the England average is shown where available."
: "No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish school-level figures for 2021/22 — the England average is shown for those years where available."}
</p> </p>
)} )}
+10 -3
View File
@@ -118,7 +118,7 @@ export function RankingsView({
<header className={styles.header}> <header className={styles.header}>
<h1>School Rankings</h1> <h1>School Rankings</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
Top-performing schools by {metricLabel.toLowerCase()} Schools ranked by {metricLabel.toLowerCase()}
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> showing top {rankings.length}</span>} {!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> showing top {rankings.length}</span>}
</p> </p>
</header> </header>
@@ -143,7 +143,10 @@ export function RankingsView({
<p className={styles.metricDescription}>{currentMetricDef.description}</p> <p className={styles.metricDescription}>{currentMetricDef.description}</p>
)} )}
{isProgressScore && ( {isProgressScore && (
<p className={styles.progressHint}>Progress scores: 0 = national average. Positive = above average.</p> <p className={styles.progressHint}>
Progress scores: 0 = national average. Positive = above average.
{isPrimary && ' KS2 progress isnt published for 2023/24 or 2024/25 (theres no key stage 1 baseline) — pick an earlier year to rank by it.'}
</p>
)} )}
{/* Filters */} {/* Filters */}
@@ -218,7 +221,11 @@ export function RankingsView({
{rankings.length === 0 ? ( {rankings.length === 0 ? (
<EmptyState <EmptyState
title="No rankings found" title="No rankings found"
message="Try selecting a different metric, area, or year." message={
isPrimary && isProgressScore
? 'KS2 progress scores arent published for the most recent years (2023/24 and 2024/25) because theres no key stage 1 baseline. Select an earlier year to see progress rankings.'
: 'Try selecting a different metric, area, or year.'
}
action={{ action={{
label: 'Clear filters', label: 'Clear filters',
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`), onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
+31 -15
View File
@@ -44,30 +44,46 @@
background: var(--bg-secondary, #f3ede4); background: var(--bg-secondary, #f3ede4);
} }
/* ── National average marker ── */ /* ── Per-bar national average marker ──
.natLine { Each bar compares against its own benchmark (expected vs higher standard /
greater depth), so the marker sits on the individual bar's track rather than
as one line spanning both bars. */
.natTick {
position: absolute; position: absolute;
top: 0; top: -3px;
height: calc(100% - 20px); bottom: -3px;
width: 1.5px; width: 2px;
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */ transform: translateX(-50%);
z-index: 2; background: var(--accent-coral, #e07256);
border-radius: 2px;
z-index: 4;
pointer-events: none; pointer-events: none;
} }
.natPill { .natTick::before {
content: '';
position: absolute; position: absolute;
top: -10px; top: -3px;
left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-coral, #e07256); background: var(--accent-coral, #e07256);
color: #fff; }
.barHeaderRight {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.natLabel {
font-size: 0.55rem; font-size: 0.55rem;
font-weight: 700; font-weight: 700;
padding: 0.1rem 0.35rem; color: var(--accent-coral-dark, #b04a2e);
border-radius: 4px; text-transform: uppercase;
white-space: nowrap; letter-spacing: 0.03em;
z-index: 3;
letter-spacing: 0.02em;
} }
/* ── Bar rows ── */ /* ── Bar rows ── */
+52 -34
View File
@@ -8,6 +8,9 @@ interface SubjectData {
expectedPct: number | null; expectedPct: number | null;
exceedingPct: number | null; exceedingPct: number | null;
nationalExpectedPct: number | null; nationalExpectedPct: number | null;
/** National higher-standard / greater-depth benchmark for the exceeding
* bar — a different figure from the expected national. */
nationalExceedingPct: number | null;
} }
interface SatsChartProps { interface SatsChartProps {
@@ -17,11 +20,45 @@ interface SatsChartProps {
const RULER_TICKS = [0, 25, 50, 75, 100]; const RULER_TICKS = [0, 25, 50, 75, 100];
const GRIDLINE_POSITIONS = [25, 50, 75]; const GRIDLINE_POSITIONS = [25, 50, 75];
/** One results bar with a national marker positioned on its own track, so the
* expected and exceeding bars each compare against their own benchmark. */
function BarRow({
label,
value,
national,
barRef,
barClass,
}: {
label: string;
value: number;
national: number | null;
barRef: React.RefObject<HTMLDivElement | null>;
barClass: string;
}) {
return (
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>{label}</span>
<span className={styles.barHeaderRight}>
{national != null && <span className={styles.natLabel}>nat {national.toFixed(0)}%</span>}
<span className={styles.barLabel}>{value.toFixed(0)}%</span>
</span>
</div>
<div className={styles.barTrack}>
<div ref={barRef} className={`${styles.bar} ${barClass}`} data-width={value} />
{national != null && (
<div className={styles.natTick} style={{ left: `${national}%` }} aria-hidden="true" />
)}
</div>
</div>
);
}
function SubjectColumn({ subject }: { subject: SubjectData }) { function SubjectColumn({ subject }: { subject: SubjectData }) {
const expectedRef = useRef<HTMLDivElement>(null); const expectedRef = useRef<HTMLDivElement>(null);
const exceedingRef = useRef<HTMLDivElement>(null); const exceedingRef = useRef<HTMLDivElement>(null);
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject; const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
// Animate bars on mount // Animate bars on mount
useEffect(() => { useEffect(() => {
@@ -51,44 +88,25 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
))} ))}
</div> </div>
{/* National average marker */} {/* Bars — each carries its own national marker */}
{nationalExpectedPct != null && (
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
</div>
)}
{/* Bars */}
<div className={styles.barGroup}> <div className={styles.barGroup}>
{expectedPct != null && ( {expectedPct != null && (
<div className={styles.barRow}> <BarRow
<div className={styles.barHeader}> label="Expected"
<span className={styles.barLabelSuffix}>Expected</span> value={expectedPct}
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span> national={nationalExpectedPct}
</div> barRef={expectedRef}
<div className={styles.barTrack}> barClass={styles.barExpected}
<div
ref={expectedRef}
className={`${styles.bar} ${styles.barExpected}`}
data-width={expectedPct}
/> />
</div>
</div>
)} )}
{exceedingPct != null && ( {exceedingPct != null && (
<div className={styles.barRow}> <BarRow
<div className={styles.barHeader}> label="Exceeding"
<span className={styles.barLabelSuffix}>Exceeding</span> value={exceedingPct}
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span> national={nationalExceedingPct}
</div> barRef={exceedingRef}
<div className={styles.barTrack}> barClass={styles.barExceeding}
<div
ref={exceedingRef}
className={`${styles.bar} ${styles.barExceeding}`}
data-width={exceedingPct}
/> />
</div>
</div>
)} )}
</div> </div>
@@ -135,7 +153,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
Exceeding / high score Exceeding / high score
</div> </div>
<div className={styles.legendItem}> <div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} /> <div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
National average National average
</div> </div>
</div> </div>
@@ -1040,6 +1040,41 @@
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);
+196 -64
View File
@@ -18,9 +18,11 @@ import type {
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { import {
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
} from '@/lib/utils'; } from '@/lib/utils';
import { DeltaChip } from './DeltaChip'; import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
import { summariseAdmissions } from '@/lib/compareLogic';
const PerformanceChart = dynamic( const PerformanceChart = dynamic(
() => import('./PerformanceChart').then((m) => m.PerformanceChart), () => import('./PerformanceChart').then((m) => m.PerformanceChart),
@@ -85,6 +87,10 @@ export function SchoolDetailView({
// Trend toggle only appears with ≥2 years carrying an offer rate. // Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length; const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissionsOfferYears >= 2; const showAdmissionsTrend = admissionsOfferYears >= 2;
// Banded interpretation of the first-choice offer rate ("More than half of
// first choices missed out" etc.) — the same banding the compare screen
// uses, so a low offer rate reads as how severe it actually is.
const admissionsSummary = summariseAdmissions(admissions);
// Only the section links scroll horizontally; Back and "All" stay pinned. // Only the section links scroll horizontally; Back and "All" stay pinned.
const sectionLinksRef = useRef<HTMLDivElement | null>(null); const sectionLinksRef = useRef<HTMLDivElement | null>(null);
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false); const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
@@ -153,10 +159,16 @@ export function SchoolDetailView({
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// Phase detection // Phase detection. All-through schools cover BOTH key stages, so they are
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
// have KS4 data) but isAllThrough gates the primary-only content (phonics,
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
const phase = schoolInfo.phase ?? ''; const phase = schoolInfo.phase ?? '';
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through'; const isAllThrough = phase.toLowerCase() === 'all-through';
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
const isPrimary = !isSecondary; const isPrimary = !isSecondary;
// Primary-stage content shows for pure-primary AND all-through schools.
const showPrimaryContent = isPrimary || isAllThrough;
// National averages (fetched dynamically so they stay current) // National averages (fetched dynamically so they stay current)
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null); const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
@@ -222,17 +234,38 @@ 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,
// after the recognised Ofsted badge; low-demand context sections stay last. // after the recognised Ofsted badge; low-demand context sections stay last.
const navItems: { id: string; label: string }[] = []; const navItems: { id: string; label: string }[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' }); if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' }); if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' }); if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' });
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' }); if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -269,18 +302,28 @@ export function SchoolDetailView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItems.map(n => n.id).join(',')]); }, [navItems.map(n => n.id).join(',')]);
// A report card is identified by the presence of report-card area
// judgements, NOT by `framework` — the API sets `framework` to the raw
// event grouping (e.g. "Schools - S5") even for report-card schools, so
// the old `framework === 'ReportCard'` test never matched and report cards
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
const isReportCard = !!(
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
);
// A report card is dated by its own inspection (rc_inspection_date); the
// legacy inspection_date belongs to an older inspection and must never
// date a report card (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ─────────── // ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAllSameGrade = (() => { const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
if (!ofsted || ofsted.framework === 'ReportCard') return false; const oeifAllSameGrade =
const subs = [ !!ofsted &&
ofsted.quality_of_education, !isReportCard &&
ofsted.behaviour_attitudes, oeifAreas.length >= 3 &&
ofsted.personal_development, oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
ofsted.leadership_management,
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
].filter((v): v is number => v != null);
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
})();
// Label shown in the mobile "section" menu button — the section in view. // Label shown in the mobile "section" menu button — the section in view.
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? ''; const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
@@ -309,6 +352,9 @@ export function SchoolDetailView({
{schoolInfo.school_type && ( {schoolInfo.school_type && (
<span className={styles.metaItem}>{schoolInfo.school_type}</span> <span className={styles.metaItem}>{schoolInfo.school_type}</span>
)} )}
{isAllThrough && (
<span className={styles.metaItem}>All-through (primary &amp; secondary)</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span> <span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)} )}
@@ -507,10 +553,10 @@ export function SchoolDetailView({
{ofsted && ( {ofsted && (
<section id="ofsted" className={styles.card}> <section id="ofsted" className={styles.card}>
<h2 className={styles.sectionTitle}> <h2 className={styles.sectionTitle}>
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'} {isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofsted.inspection_date && ( {ofstedInspectedDate && (
<span className={styles.ofstedDate}> <span className={styles.ofstedDate}>
Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span> </span>
)} )}
<a <a
@@ -525,13 +571,13 @@ export function SchoolDetailView({
</a> </a>
</h2> </h2>
{ofsted.framework === 'ReportCard' ? ( {isReportCard ? (
/* ── New Report Card layout ── */ /* ── New Report Card layout ── */
<> <>
<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}> <div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{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>
@@ -568,23 +614,17 @@ export function SchoolDetailView({
)} )}
</div> </div>
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools. {ofsted.grade_source === 'ungraded_carried_forward'
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}
</p> </p>
{oeifAllSameGrade ? ( {oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}> <p className={styles.ofstedAllSame}>
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}> <div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{[ {oeifAreas.map(({ label, value }) => (
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
{ label: 'School Leadership', value: ofsted.leadership_management },
...(ofsted.early_years_provision != null
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
: []),
].map(({ label, value }) => value != null && (
<div key={label} className={styles.metricCard}> <div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div> <div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}> <div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
@@ -603,17 +643,31 @@ export function SchoolDetailView({
{hasAnyResults && latestResults && ( {hasAnyResults && latestResults && (
<section id="results" className={styles.card}> <section id="results" className={styles.card}>
<h2 className={styles.sectionTitle}> <h2 className={styles.sectionTitle}>
{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}>
{isSecondary {isSpecial
? 'GCSE results for Year 11 pupils. National averages shown for comparison.' ? (isSecondary
: 'End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.'} ? '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> </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 && (
<> <>
{isAllThrough && (
<h3 className={styles.subSectionTitle}>Primary KS2 SATs (Year 6)</h3>
)}
<div className={styles.heroStatGrid}> <div className={styles.heroStatGrid}>
{latestResults.rwm_expected_pct !== null && ( {latestResults.rwm_expected_pct !== null && (
<div className={styles.heroStatCard}> <div className={styles.heroStatCard}>
@@ -623,7 +677,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.heroStatValue}> <div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_expected_pct)} {formatPercentage(latestResults.rwm_expected_pct)}
{primaryAvg.rwm_expected_pct != null && ( {!suppressKs2Comparison && 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}
@@ -632,8 +686,8 @@ export function SchoolDetailView({
/> />
)} )}
</div> </div>
{primaryAvg.rwm_expected_pct != null && ( {!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -645,7 +699,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.heroStatValue}> <div className={styles.heroStatValue}>
{formatPercentage(latestResults.rwm_high_pct)} {formatPercentage(latestResults.rwm_high_pct)}
{primaryAvg.rwm_high_pct != null && ( {!suppressKs2Comparison && 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}
@@ -654,14 +708,15 @@ export function SchoolDetailView({
/> />
)} )}
</div> </div>
{primaryAvg.rwm_high_pct != null && ( {!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
<div className={styles.heroStatHint}>National 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>
{latestResults.rwm_expected_pct != null && {!suppressKs2Comparison &&
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 && (
@@ -685,28 +740,38 @@ export function SchoolDetailView({
</div> </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 <SatsChart
subjects={[ subjects={[
{ {
name: 'Reading', name: 'Reading',
expectedPct: latestResults.reading_expected_pct, expectedPct: latestResults.reading_expected_pct,
exceedingPct: latestResults.reading_high_pct, exceedingPct: latestResults.reading_high_pct,
nationalExpectedPct: primaryAvg.reading_expected_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
}, },
{ {
name: 'Writing', name: 'Writing',
expectedPct: latestResults.writing_expected_pct, expectedPct: latestResults.writing_expected_pct,
exceedingPct: latestResults.writing_high_pct, exceedingPct: latestResults.writing_high_pct,
nationalExpectedPct: primaryAvg.writing_expected_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
// Writing's higher level is teacher-assessed "greater depth".
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
}, },
{ {
name: 'Maths', name: 'Maths',
expectedPct: latestResults.maths_expected_pct, expectedPct: latestResults.maths_expected_pct,
exceedingPct: latestResults.maths_high_pct, exceedingPct: latestResults.maths_high_pct,
nationalExpectedPct: primaryAvg.maths_expected_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_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) && (
@@ -752,6 +817,9 @@ export function SchoolDetailView({
{/* ── Secondary / KS4 content ── */} {/* ── Secondary / KS4 content ── */}
{hasKS4Results && ( {hasKS4Results && (
<> <>
{isAllThrough && (
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary GCSEs (Year 11)</h3>
)}
<div className={styles.metricsGrid}> <div className={styles.metricsGrid}>
{latestResults.attainment_8_score !== null && ( {latestResults.attainment_8_score !== null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
@@ -760,8 +828,8 @@ 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>
{secondaryAvg.attainment_8_score != null && ( {!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div> <div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)} )}
</div> </div>
)} )}
@@ -784,8 +852,8 @@ 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>
{secondaryAvg.english_maths_standard_pass_pct != null && ( {!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>National 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>
)} )}
@@ -796,8 +864,8 @@ 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>
{secondaryAvg.english_maths_strong_pass_pct != null && ( {!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>National 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>
)} )}
@@ -861,6 +929,15 @@ export function SchoolDetailView({
</div> </div>
)} )}
</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}> <div className={styles.admissionsViewport}>
{/* This-year Q&A */} {/* This-year Q&A */}
@@ -902,6 +979,9 @@ export function SchoolDetailView({
</div> </div>
)} )}
</dl> </dl>
{admissionsSummary.chip && (
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
)}
</div> </div>
{/* Multi-year trend */} {/* Multi-year trend */}
@@ -937,7 +1017,7 @@ export function SchoolDetailView({
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" /> <DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
)} )}
</div> </div>
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div> <div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
</div> </div>
)} )}
{latestResults?.eal_pct != null && ( {latestResults?.eal_pct != null && (
@@ -953,7 +1033,7 @@ export function SchoolDetailView({
)} )}
</div> </div>
{primaryAvg.eal_pct != null && ( {primaryAvg.eal_pct != null && (
<div className={styles.heroStatHint}>National avg: {primaryAvg.eal_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -970,7 +1050,7 @@ export function SchoolDetailView({
)} )}
</div> </div>
{primaryAvg.sen_support_pct != null && ( {primaryAvg.sen_support_pct != null && (
<div className={styles.heroStatHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -1034,16 +1114,52 @@ export function SchoolDetailView({
{yearlyData.length > 0 && ( {yearlyData.length > 0 && (
<section id="history" className={styles.card}> <section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Results Over Time</h2> <h2 className={styles.sectionTitle}>Results Over Time</h2>
{isAllThrough ? (
// All-through: KS2 and KS4 trends are on different scales and have
// different gap stories, so render them as two stacked charts
// rather than crowding 8+ series onto one axis.
<>
{hasKS2Results && (
<>
<h3 className={styles.subSectionTitle}>Primary KS2 SATs</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={false}
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year}
/>
</div>
</>
)}
{hasKS4Results && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary GCSEs</h3>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={true}
nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
/>
</div>
</>
)}
</>
) : (
<div className={styles.chartContainer}> <div className={styles.chartContainer}>
<PerformanceChart <PerformanceChart
data={yearlyData} data={yearlyData}
schoolName={schoolInfo.school_name} schoolName={schoolInfo.school_name}
isSecondary={isSecondary} isSecondary={isSecondary}
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null} nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null} nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
nationalByYear={nationalAvg?.by_year} nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
/> />
</div> </div>
)}
{yearlyData.length > 1 && ( {yearlyData.length > 1 && (
<details className={styles.historyDisclosure}> <details className={styles.historyDisclosure}>
<summary className={styles.historyToggle}>View raw year-by-year data</summary> <summary className={styles.historyToggle}>View raw year-by-year data</summary>
@@ -1052,7 +1168,15 @@ export function SchoolDetailView({
<thead> <thead>
<tr> <tr>
<th>Year</th> <th>Year</th>
{isSecondary ? ( {isAllThrough ? (
<>
<th>RWM (expected %)</th>
<th>Exceeding (%)</th>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>English &amp; Maths Grade 4+</th>
</>
) : isSecondary ? (
<> <>
<th>Attainment 8</th> <th>Attainment 8</th>
<th>Progress 8</th> <th>Progress 8</th>
@@ -1074,7 +1198,15 @@ export function SchoolDetailView({
{yearlyData.map((result) => ( {yearlyData.map((result) => (
<tr key={result.year}> <tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td> <td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
{isSecondary ? ( {isAllThrough ? (
<>
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
</>
) : isSecondary ? (
<> <>
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td> <td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td> <td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
@@ -1099,8 +1231,8 @@ export function SchoolDetailView({
)} )}
</section> </section>
)} )}
{/* Year 1 Phonics — primary only */} {/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
{hasPhonics && isPrimary && phonics && ( {hasPhonics && showPrimaryContent && phonics && (
<section id="phonics" className={styles.card}> <section id="phonics" className={styles.card}>
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2> <h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
@@ -1142,7 +1274,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div> <div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
{primaryAvg.overall_absence_pct != null && ( {primaryAvg.overall_absence_pct != null && (
<div className={styles.metricHint}>National avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div> <div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
)} )}
</div> </div>
)} )}
@@ -1154,7 +1286,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div> <div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
{primaryAvg.persistent_absence_pct != null && ( {primaryAvg.persistent_absence_pct != null && (
<div className={styles.metricHint}>National avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div> <div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
+19 -6
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 } from '@/lib/utils'; import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import styles from './SchoolRow.module.css'; import styles from './SchoolRow.module.css';
interface SchoolRowProps { interface SchoolRowProps {
@@ -38,10 +38,23 @@ export function SchoolRow({
school.religious_denomination && school.religious_denomination &&
school.religious_denomination !== 'Does not apply'; 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 = const rwmDelta =
school.rwm_expected_pct != null && nationalAvgRwm != null showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null
? Math.round(school.rwm_expected_pct - nationalAvgRwm) ? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
: null; : null;
const handleCompareClick = () => { const handleCompareClick = () => {
@@ -87,9 +100,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}>
{school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'} {showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
</strong> </strong>
{school.prev_rwm_expected_pct != null && ( {showRwmValue && 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)}`}
@@ -2,14 +2,6 @@
padding: 1.5rem; padding: 1.5rem;
} }
.title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 1.5rem;
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.warning { .warning {
background: var(--accent-gold-bg); background: var(--accent-gold-bg);
border: 1px solid var(--accent-gold, #c9a227); border: 1px solid var(--accent-gold, #c9a227);
@@ -119,12 +111,16 @@
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
margin-bottom: 0.5rem; margin-bottom: 0.25rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.resultButton {
flex: 0 0 auto;
}
.schoolMeta { .schoolMeta {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -159,21 +155,30 @@
padding: 1rem; padding: 1rem;
} }
.title { /* One scroll container on mobile: the modal content itself scrolls, so the
font-size: 1.25rem; results list must not add its own inner scroll (double scrollbars, and
the input would be trapped above a short 400px window when the keyboard
shrinks the sheet). */
.results {
max-height: none;
overflow: visible;
} }
/* Compact stacked card: name + meta, then a full-width action so the tap
target is obvious and the card doesn't waste vertical space. */
.resultItem { .resultItem {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 0.625rem;
padding: 0.875rem;
} }
.addButton { .resultButton {
width: 100%; width: 100%;
} }
.schoolMeta { .schoolMeta {
flex-direction: column; flex-wrap: wrap;
gap: 0.25rem; gap: 0.25rem 1rem;
} }
} }
+3 -5
View File
@@ -81,10 +81,8 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
}; };
return ( return (
<Modal isOpen={isOpen} onClose={handleClose}> <Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
<div className={styles.modalContent}> <div className={styles.modalContent}>
<h2 className={styles.title}>Add School to Comparison</h2>
{!canAddMore && ( {!canAddMore && (
<div className={styles.warning}> <div className={styles.warning}>
Maximum 5 schools can be compared. Remove a school to add another. Maximum 5 schools can be compared. Remove a school to add another.
@@ -129,9 +127,9 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
<button <button
onClick={() => handleAddSchool(school)} onClick={() => handleAddSchool(school)}
disabled={alreadySelected || !canAddMore} disabled={alreadySelected || !canAddMore}
className={ className={`${styles.resultButton} ${
alreadySelected ? "btn btn-active" : "btn btn-secondary" alreadySelected ? "btn btn-active" : "btn btn-secondary"
} }`}
> >
{alreadySelected ? "✓ Comparing" : "+ Compare"} {alreadySelected ? "✓ Comparing" : "+ Compare"}
</button> </button>
@@ -623,6 +623,41 @@
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);
@@ -1049,8 +1084,12 @@
font-size: 1.85rem; font-size: 1.85rem;
} }
/* On mobile let the chart container flow naturally — PerformanceChart's
own .chartWrapper carries the definite canvas height (220px) plus the
chip strip above it. A fixed 220px here double-constrained the two and
clipped the chips onto the plot area. */
.chartContainer { .chartContainer {
height: 220px; height: auto;
} }
.dataTable { .dataTable {
@@ -23,8 +23,9 @@ 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 } from '@/lib/utils'; import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } 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';
@@ -107,6 +108,16 @@ 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';
@@ -186,18 +197,27 @@ export function SecondarySchoolDetailView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItems.map(n => n.id).join(',')]); }, [navItems.map(n => n.id).join(',')]);
// A report card is identified by the presence of report-card area
// judgements, NOT by `framework` — the API sets `framework` to the raw
// event grouping (e.g. "Schools - S5") even for report-card schools, so
// the old `framework === 'ReportCard'` test never matched and report cards
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
const isReportCard = !!(
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
);
// Report cards are dated by their own inspection (rc_inspection_date), never
// the legacy inspection_date (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ─────────── // ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAllSameGrade = (() => { const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
if (!ofsted || ofsted.framework === 'ReportCard') return false; const oeifAllSameGrade =
const subs = [ !!ofsted &&
ofsted.quality_of_education, !isReportCard &&
ofsted.behaviour_attitudes, oeifAreas.length >= 3 &&
ofsted.personal_development, oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
ofsted.leadership_management,
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
].filter((v): v is number => v != null);
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
})();
// National Attainment 8 baseline for the "Results Over Time" chart. // National Attainment 8 baseline for the "Results Over Time" chart.
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null; const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
@@ -332,10 +352,10 @@ export function SecondarySchoolDetailView({
{ofsted && ( {ofsted && (
<section id="ofsted" className={styles.card}> <section id="ofsted" className={styles.card}>
<h2 className={styles.sectionTitle}> <h2 className={styles.sectionTitle}>
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'} {isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofsted.inspection_date && ( {ofstedInspectedDate && (
<span className={styles.ofstedDate}> <span className={styles.ofstedDate}>
{' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} {' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span> </span>
)} )}
<a <a
@@ -349,12 +369,12 @@ export function SecondarySchoolDetailView({
Ofsted reports Ofsted reports
</a> </a>
</h2> </h2>
{ofsted.framework === 'ReportCard' ? ( {isReportCard ? (
<> <>
<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}> <div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{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>
@@ -390,23 +410,17 @@ export function SecondarySchoolDetailView({
)} )}
</div> </div>
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections. {ofsted.grade_source === 'ungraded_carried_forward'
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'}
</p> </p>
{oeifAllSameGrade ? ( {oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}> <p className={styles.ofstedAllSame}>
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}> <div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{[ {oeifAreas.map(({ label, value }) => (
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
{ label: 'School Leadership', value: ofsted.leadership_management },
...(ofsted.early_years_provision != null
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
: []),
].map(({ label, value }) => value != null && (
<div key={label} className={styles.metricCard}> <div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div> <div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}> <div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
@@ -422,7 +436,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}> <div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{[ {[
{ 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 },
@@ -449,12 +463,15 @@ 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. National averages shown for comparison. GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
</p> </p>
<SpecialSchoolNote school={schoolInfo} />
{p8Suspended && ( {p8Suspended && (
<div className={styles.p8Banner}> <div className={styles.p8Banner}>
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution. Progress 8 isn&apos;t published for 2024/25: this GCSE year group sat no KS2 tests
(COVID), so DfE has no starting point to measure their progress from.
</div> </div>
)} )}
@@ -468,7 +485,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)}
{secondaryAvg.attainment_8_score != null && ( {!suppressComparison && 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}
@@ -477,8 +494,8 @@ export function SecondarySchoolDetailView({
/> />
)} )}
</div> </div>
{secondaryAvg.attainment_8_score != null && ( {!suppressComparison && secondaryAvg.attainment_8_score != null && (
<div className={styles.heroStatHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div> <div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)} )}
</div> </div>
)} )}
@@ -508,7 +525,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)}
{secondaryAvg.english_maths_strong_pass_pct != null && ( {!suppressComparison && 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}
@@ -517,8 +534,8 @@ export function SecondarySchoolDetailView({
/> />
)} )}
</div> </div>
{secondaryAvg.english_maths_strong_pass_pct != null && ( {!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatHint}>National 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>
)} )}
@@ -530,7 +547,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)}
{secondaryAvg.english_maths_standard_pass_pct != null && ( {!suppressComparison && 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}
@@ -539,15 +556,17 @@ export function SecondarySchoolDetailView({
/> />
)} )}
</div> </div>
{secondaryAvg.english_maths_standard_pass_pct != null && ( {!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatHint}>National 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) */} {/* Attainment 8 visual bar (080 scale). This viz is explicitly
{latestResults.attainment_8_score != null && ( "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.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}>
@@ -739,8 +758,8 @@ export function SecondarySchoolDetailView({
data={yearlyData} data={yearlyData}
schoolName={schoolInfo.school_name} schoolName={schoolInfo.school_name}
isSecondary={true} isSecondary={true}
nationalAtt8Avg={heroAtt8Nat} nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
nationalByYear={nationalAvg?.by_year} nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
/> />
</div> </div>
</> </>
+8 -2
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 } from '@/lib/utils'; import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } 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,8 +55,14 @@ 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 && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null; att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
? att8 - laAvgAttainment8
: null;
const admissionsTag = detectAdmissionsTag(school); const admissionsTag = detectAdmissionsTag(school);
const sixthForm = hasSixthForm(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'; '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';
@@ -90,15 +91,19 @@ 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) => const values = latestValues(data, urns, spec.metric).map((v, i) =>
v != null ? Math.round(v) : null, v != null && !special[i] ? Math.round(v) : null,
); );
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined; const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
const anchor = const anchor =
@@ -135,12 +140,19 @@ 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 = latestValues(data, urns, 'attainment_8_score'); const att8 = dropSpecial(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--) {
@@ -148,9 +160,35 @@ export function CompareAcademics({
} }
return null; return null;
}); });
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct'); // DfE stopped publishing Progress 8 from 2024/25: those GCSE year groups
const ebacc = latestValues(data, urns, 'ebacc_entry_pct'); // sat no KS2 tests (COVID), so there is no baseline to measure progress
// from. A bare "No data" reads as a gap on our side — say why. Judged
// PER SCHOOL on its own latest data year: a school whose data simply
// stops earlier (an unrelated gap) must not borrow the COVID explanation
// from a neighbour that does have 2024/25 data.
const p8NotPublished = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return y >= 202425;
});
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 att8Anchor = nationalAverages?.secondary?.attainment_8_score;
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
// Every headline number gets its England anchor + verdict chip, so the
// "anchored against the England average" promise holds for the grade-5
// and EBacc rows too, not just Attainment 8.
const anchorChip = (value: number | null, anchor: number | null | undefined, tol: number) => {
if (value == null || anchor == null) return null;
const v = verdict(value, anchor, tol);
return (
<Chip tone={v === 'above' ? 'good' : v === 'below' ? 'warn' : 'neutral'}>
{v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average
</Chip>
);
};
return ( return (
<Section <Section
@@ -163,7 +201,8 @@ export function CompareAcademics({
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{att8[i] != null ? ( {att8[i] != null ? (
<> <>
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span> <span className={s.big}>{(att8[i] as number).toFixed(1)}</span>{' '}
{anchorChip(att8[i], att8Anchor, 2)}
{att8Anchor != null && ( {att8Anchor != null && (
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span> <span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
)} )}
@@ -189,6 +228,11 @@ export function CompareAcademics({
> >
{banding[i]} {banding[i]}
</Chip> </Chip>
) : p8NotPublished[i] ? (
<span className={s.small}>
Not published this GCSE year group sat no KS2 tests (COVID), so DfE has no
baseline to measure progress from
</span>
) : ( ) : (
<span className={s.small}>No data</span> <span className={s.small}>No data</span>
)} )}
@@ -200,14 +244,38 @@ export function CompareAcademics({
</RowLabel> </RowLabel>
{schools.map((school, i) => ( {schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>} {grade5[i] != null ? (
<>
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(grade5[i] as number)}%
</span>{' '}
{anchorChip(grade5[i], grade5Anchor, 3)}
{grade5Anchor != null && (
<span className={s.small}>England average {Math.round(grade5Anchor)}%</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell> </Cell>
))} ))}
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel> <RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
{schools.map((school, i) => ( {schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>} {ebacc[i] != null ? (
<>
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(ebacc[i] as number)}%
</span>{' '}
{anchorChip(ebacc[i], ebaccAnchor, 3)}
{ebaccAnchor != null && (
<span className={s.small}>England average {Math.round(ebaccAnchor)}%</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell> </Cell>
))} ))}
</SectionGrid> </SectionGrid>
@@ -216,8 +284,26 @@ export function CompareAcademics({
} }
const national = nationalAverages?.primary; 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; 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
// pupils the figure rests on. Taken from the SAME yearly row that supplies
// the displayed percentage: resolving eligible_pupils and the
// disadvantaged share independently could mix years and misstate the
// cohort behind the figure.
const cohorts = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (row.rwm_expected_disadvantaged_pct != null) {
if (row.eligible_pupils == null || row.disadvantaged_pct == null) return null;
const cohort = Math.round((row.eligible_pupils * row.disadvantaged_pct) / 100);
return cohort > 0 ? cohort : null;
}
}
return null;
});
return ( return (
<Section <Section
@@ -233,6 +319,7 @@ export function CompareAcademics({
urns={urns} urns={urns}
schoolNames={schoolNames} schoolNames={schoolNames}
national={national} national={national}
special={specialFlags}
/> />
))} ))}
@@ -246,6 +333,7 @@ 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}>
@@ -270,6 +358,9 @@ export function CompareAcademics({
<span className={s.big} style={{ fontSize: '1.1rem' }}> <span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(value)}% {Math.round(value)}%
</span>{' '} </span>{' '}
{cohorts[i] != null && (
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
)}{' '}
{disadvantagedAnchor != null && ( {disadvantagedAnchor != null && (
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}> <Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
{verdict(value, disadvantagedAnchor, 5) === 'above' && {verdict(value, disadvantagedAnchor, 5) === 'above' &&
@@ -170,8 +170,14 @@ export function CompareAtAGlance({
{schools.map((school, i) => { {schools.map((school, i) => {
const census = data[String(school.urn)]?.census; const census = data[String(school.urn)]?.census;
const pupils = census?.total_pupils ?? school.total_pupils ?? null; const pupils = census?.total_pupils ?? school.total_pupils ?? null;
// An all-through school's roll covers every age group, so judging
// it against the single-phase median ("Much larger than average")
// is meaningless — label the roll honestly instead.
const isAllThrough = /all.?through/i.test(school.phase ?? '');
let sizeNote: string | null = null; let sizeNote: string | null = null;
if (pupils != null && medianPupils != null) { if (isAllThrough) {
sizeNote = 'Whole-school roll (all-through, all ages)';
} else if (pupils != null && medianPupils != null) {
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average'; if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average'; else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average'; else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
@@ -48,10 +48,25 @@ export function CompareCommunity({
); );
}; };
const anyAllThrough = schools.some((school) => /all.?through/i.test(school.phase ?? ''));
return ( return (
<Section <Section
title="Who goes there" title="Who goes there"
how="The school's community, from the latest school census. State-school averages are computed from our dataset and shown for context — there's no “right” number here." how={
<>
The school&apos;s community, from the latest school census. State-school averages are
computed from our dataset and shown for context there&apos;s no &ldquo;right&rdquo;
number here.
{anyAllThrough && (
<>
{' '}
For all-through schools these figures cover the whole school, all ages not just
the {isSecondary ? 'secondary' : 'primary'} phase.
</>
)}
</>
}
> >
<SectionGrid schools={schools}> <SectionGrid schools={schools}>
<Measure label="Pupils on roll"> <Measure label="Pupils on roll">
@@ -239,8 +239,17 @@ export function CompareOfsted({
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`; `https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
return ( return (
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer"> {/* Short visible label to save space on mobile (the coloured
{school.school_name}&apos;s Ofsted page school name already leads the row); the full name stays in
aria-label so screen readers can tell the links apart. */}
<a
className={s.link}
href={url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${school.school_name}'s Ofsted page`}
>
Ofsted page
</a> </a>
</Cell> </Cell>
); );
@@ -60,18 +60,10 @@
margin: 0 0 1rem; margin: 0 0 1rem;
} }
/* ComparisonChart runs Chart.js with maintainAspectRatio:false, so it fills /* ComparisonChart owns its own canvas height now (a definite px value per
its container's height which must be *definite*. A min-height alone does breakpoint), with the mobile chip legend above and the gap note below it
not resolve the chart wrapper's height:100%, leaving Chart.js to fall back flowing at natural size. This box therefore only needs to not constrain
to its ~150px default (a squashed sliver). Give it a real height. */ that height no fixed height, or the note would again eat the plot. */
.chartBox { .chartBox {
height: 420px; min-height: 0;
}
@media (max-width: 640px) {
/* Taller on mobile: the mobile-only school chips sit above the canvas and
wrap to two rows for 3+ schools, so the plot keeps a usable height. */
.chartBox {
height: 360px;
}
} }
@@ -119,6 +119,7 @@ export function TrendsExplorer({
metric={metric} metric={metric}
metricLabel={metricLabel} metricLabel={metricLabel}
nationalByYear={nationalByYear} nationalByYear={nationalByYear}
isSecondary={!isPrimaryPhase}
/> />
</div> </div>
</div> </div>
@@ -72,15 +72,18 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
flex-basis: 100%; flex-basis: 100%;
font-size: 0.8rem; /* Slightly larger than the values below it so the school each row belongs
to is easy to read on mobile (hidden on desktop, where the column header
names the school). */
font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: var(--sc, var(--text-secondary)); color: var(--sc, var(--text-secondary));
margin-bottom: 0.15rem; margin-bottom: 0.2rem;
} }
.cellDot { .cellDot {
width: 9px; width: 10px;
height: 9px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: var(--dot, var(--text-muted)); background: var(--dot, var(--text-muted));
flex: none; flex: none;
+11 -6
View File
@@ -53,6 +53,10 @@ export interface CompareChart {
/** True when England published a 2021/22 figure but no school has one /** True when England published a 2021/22 figure but no school has one
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */ * the UI shows: "DfE didn't publish school-level figures for 2021/22". */
showUnpublished202122Note: boolean; showUnpublished202122Note: boolean;
/** Years where the England overlay has a value but no school does the
* chart shows a dashed-line-only stretch that needs explaining (KS2 and
* KS4 have different honest explanations, so the component owns the copy). */
englandOnlyYears: number[];
} }
export function buildCompareChart( export function buildCompareChart(
@@ -92,11 +96,12 @@ export function buildCompareChart(
} }
} }
const idx202122 = years.indexOf(202122); const englandOnlyYears = years.filter(
const showUnpublished202122Note = (year, i) =>
idx202122 >= 0 && englandDataset?.data[i] != null && schoolDatasets.every((ds) => ds.data[i] == null),
englandDataset?.data[idx202122] != null && );
schoolDatasets.every((ds) => ds.data[idx202122] == null);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note }; const showUnpublished202122Note = englandOnlyYears.includes(202122);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note, englandOnlyYears };
} }
+6
View File
@@ -192,6 +192,12 @@ export function summariseAdmissions(
if (pct != null) { if (pct != null) {
if (pct >= 100) { if (pct >= 100) {
chip = { tone: 'good', text: 'All first choices offered' }; chip = { tone: 'good', text: 'All first choices offered' };
} else if (pct < 50) {
// Banded, not one blanket chip: "Over 1 in 4" on a school where more
// than half missed out understated the worst cases by half.
chip = { tone: 'warn', text: 'More than half of first choices missed out' };
} else if (pct < 67) {
chip = { tone: 'warn', text: 'About 1 in 3 first choices missed out' };
} else if (pct < 75) { } else if (pct < 75) {
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' }; chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
} else { } else {
+3
View File
@@ -68,6 +68,9 @@ export interface School {
// Ofsted (for list view — summary only) // Ofsted (for list view — summary only)
ofsted_grade?: 1 | 2 | 3 | 4 | null; ofsted_grade?: 1 | 2 | 3 | 4 | null;
/** Report-card inspection date (Nov 2025+); non-null identifies a report
* card in the list/map, where the full report_card object isn't available. */
ofsted_rc_date?: string | null;
ofsted_date?: string | null; ofsted_date?: string | null;
ofsted_framework?: string | null; ofsted_framework?: string | null;
} }
+71 -6
View File
@@ -680,6 +680,42 @@ export function buildSchoolSummary(
return parts.join(', ') + '.'; return parts.join(', ') + '.';
} }
// ─── Legacy (OEIF) sub-judgement areas ────────────────────────────────────────
export interface OfstedLegacyArea {
label: string;
value: number;
}
/**
* The published OEIF sub-judgement areas for the legacy Ofsted layout, in
* display order. Only real grades (14) are returned: Ofsted's sentinel
* codes for "not applicable / no judgement" (9, and any 0/8 variants) and
* nulls are filtered out, so a cryptic "9" never renders as a rating.
* Sixth Form provision is included where a school has one it was
* previously dropped from the detail grid entirely.
*/
export function ofstedLegacyAreas(ofsted: {
quality_of_education?: number | null;
behaviour_attitudes?: number | null;
personal_development?: number | null;
leadership_management?: number | null;
early_years_provision?: number | null;
sixth_form_provision?: number | null;
}): OfstedLegacyArea[] {
const candidates: Array<[string, number | null | undefined]> = [
['Quality of Teaching', ofsted.quality_of_education],
['Behaviour in School', ofsted.behaviour_attitudes],
["Pupils' Wider Development", ofsted.personal_development],
['School Leadership', ofsted.leadership_management],
['Early Years (Reception)', ofsted.early_years_provision],
['Sixth Form', ofsted.sixth_form_provision],
];
return candidates
.filter((c): c is [string, number] => c[1] != null && c[1] >= 1 && c[1] <= 4)
.map(([label, value]) => ({ label, value }));
}
// ─── List-level Ofsted badge ────────────────────────────────────────────────── // ─── List-level Ofsted badge ──────────────────────────────────────────────────
export interface OfstedListBadge { export interface OfstedListBadge {
@@ -691,9 +727,12 @@ export interface OfstedListBadge {
/** /**
* Build the Ofsted badge for a school card in the list/map view. * Build the Ofsted badge for a school card in the list/map view.
* Three states: * States, in priority order:
* - Report Card school (ofsted_rc_date set): "Report Card · YYYY" in purple.
* Checked FIRST so it wins over any carried-forward legacy grade the
* list has no full report_card object, and ofsted_framework is the raw
* event grouping ("Schools - S5"), never "ReportCard".
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed * - OEIF school (ofsted_grade set): grade word + year, colour-keyed
* - ReportCard school (ofsted_framework === 'ReportCard'): "Report Card · YYYY" in purple
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no * - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
* longer issues an overall judgement): "Inspected · YYYY" mirrors the * longer issues an overall judgement): "Inspected · YYYY" mirrors the
* detail page's hero chip so a school never reads as both inspected and * detail page's hero chip so a school never reads as both inspected and
@@ -704,7 +743,16 @@ export function buildOfstedListBadge(school: {
ofsted_grade?: 1 | 2 | 3 | 4 | null; ofsted_grade?: 1 | 2 | 3 | 4 | null;
ofsted_date?: string | null; ofsted_date?: string | null;
ofsted_framework?: string | null; ofsted_framework?: string | null;
ofsted_rc_date?: string | null;
}): OfstedListBadge { }): OfstedListBadge {
// A report card wins over any carried-forward legacy grade — signalled by
// ofsted_rc_date. ofsted_framework is the raw event grouping ("Schools -
// S5"), never "ReportCard", so it can't detect report cards.
if (school.ofsted_rc_date) {
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
return { label: `Report Card · ${rcYear}`, cssClass: 'ofstedRc' };
}
const year = school.ofsted_date const year = school.ofsted_date
? new Date(school.ofsted_date).getFullYear() ? new Date(school.ofsted_date).getFullYear()
: null; : null;
@@ -723,10 +771,6 @@ export function buildOfstedListBadge(school: {
}; };
} }
if (school.ofsted_framework === 'ReportCard') {
return { label: `Report Card${yearStr}`, cssClass: 'ofstedRc' };
}
// An inspection is on record (date or framework present) but carries no // An inspection is on record (date or framework present) but carries no
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school // overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
// that has genuinely never been inspected. // that has genuinely never been inspected.
@@ -751,3 +795,24 @@ 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);
}
+1 -1
View File
@@ -180,7 +180,7 @@ with DAG(
dbt_build_ees = BashOperator( dbt_build_ees = BashOperator(
task_id="dbt_build", task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+", bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+",
) )
sync_typesense_ees = BashOperator( sync_typesense_ees = BashOperator(