Compare commits

...
Author SHA1 Message Date
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
TudorandClaude Fable 5 5944d88f0b fix(compare): expert sign-off must-fixes — phase-matched admissions, Ofsted sentinel codes, selective-school copy
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 17s
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 2m53s
M1: admissions rounds are now selected by the active phase tab
(admissionsForPhase) — an all-through school's Year 7 round no longer
masquerades as Reception odds beside pure primaries; honest per-cell and
section fallbacks name the round (Reception / Year 7).
M2: Ofsted sentinel codes (9 = not applicable) never render as judgement
chips, and the sixth-form judgement — previously dropped — now renders
for schools that have one.
M3: 'What this means' is phase- and type-aware: selective schools get
entrance-test framing, secondary faith schools a faith-criteria note, and
the primaries' distance template never appears on the secondary tab
(admissions_policy now exposed in compare school_info).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 12:40:42 +01:00
tudor 163b501be6 Merge pull request 'fix(compare): stale-basket fetch blanking comparisons; rail caption' (#52) from fix/compare-final-review-mustfix 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) Failing after 43s
Reviewed-on: #52
2026-07-17 06:28:27 +00:00
TudorandClaude Fable 5 80176cac4d fix(compare): drop the merge-on-fetch — cancellation alone fixes the race
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 10s
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 9s
Review finding: merging left a re-added school's old entry in the map
forever, so the covered check served stale data and the map grew
unboundedly. The cancelled flag already discards superseded responses,
which is the actual race fix; replacing keeps the map bounded to the
current selection and guarantees fresh refetches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 07:27:10 +01:00
TudorandClaude Fable 5 84baf95f68 fix(compare): stale basket fetch no longer blanks a freshly opened comparison
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 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m23s
Opening a compare link while localStorage held a different basket raced a
fetch for the OLD school set against the URL's SSR data; the stale
response replaced comparisonData, so no active school had data and every
section (including the trends chart) vanished until a hard refresh.
Responses from superseded effect runs are now dropped, successful ones
merge instead of replace, and the URL-seed effect re-runs when a
client-side navigation changes ?urns=.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 07:16:04 +01:00
TudorandClaude Fable 5 99b769ca9e feat(compare): fill the sticky bar's label rail with a comparison caption
'COMPARING / 3 primary schools' — 0.72rem uppercase eyebrow over a
0.95rem semibold count, sized to sit alongside the 0.92rem chip names
without dominating. Desktop only; mobile pills unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 21:27:25 +01:00
tudor e8f78a1598 Merge pull request 'fix(compare): align sticky school chips with their data columns on desktop' (#51) from fix/compare-final-review-mustfix 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 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 40s
Reviewed-on: #51
2026-07-16 20:10:45 +00:00
TudorandClaude Fable 5 8e0b730629 fix(compare): align sticky school chips with their data columns on desktop
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 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 8s
The bar was a plain flex row while sections use a 200px-label + per-school
grid, so chips drifted off the columns they label. The bar now shares the
sections' grid template (via --school-count) at the same 761px breakpoint;
verified pixel-exact against live section columns (236/615/994). Mobile
pills unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 21:05:16 +01:00
tudor 20a27f3958 Merge pull request 'fix(compare): five must-fix findings from the final expert review' (#50) from fix/compare-final-review-mustfix into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
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 1m33s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 51s
Reviewed-on: #50
2026-07-16 19:16:54 +00:00
TudorandClaude Fable 5 852ed11e4d chore: untrack dbt build artifacts (target/, logs/, .user.yml) and gitignore them
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 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 46s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m25s
Swept in accidentally by a broad 'git add pipeline'. They embed local
absolute paths and a personal usage-tracking UUID, and a stale committed
manifest causes partial-parse/version-mismatch noise for others.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 20:09:10 +01:00
TudorandClaude Fable 5 77d7052662 docs(spec): re-verify the 2021/22 KS2 gap — expert finding refuted, footnote copy stands
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 47s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m1s
The final review claimed DfE published school-level 2021/22 KS2 in Dec
2022. Re-verified: the GOV.UK announcement 'Primary school performance
tables: 2022' is CANCELLED ('will not be published in key stage 2
performance tables in academic year 2021/22'), and the EES 2021/22
release carries the same statement. No code change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 19:12:07 +01:00
TudorandClaude Fable 5 c9e324635b fix(data): official DfE KS4 national headline averages; drop mislabelled computed means
New ees_ks4_national stream ingests the EES 'National characteristics
summary data' series (England, state-funded, all pupils). The old mart's
unweighted school means were 7-15 points off every headline measure and
produced an impossible national Progress 8 (-0.27). The API's computed
fallback is gone too: the footnote calls these figures official, so an
unbuilt mart now yields an empty series, never a stand-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 19:07:49 +01:00
TudorandClaude Fable 5 1d855f3c17 fix(compare): census-sourced FSM/EAL benchmarks; never fall back across measure definitions
The FSM chip anchored against disadvantaged_pct (a different measure,
FSM6+CLA) whenever fsm_pct was null — which it always was, since the
performance df has no fsm_pct. New fact_census_benchmarks mart supplies
pupil-weighted FSM/EAL means per phase; the KS2-column medians that
produced a bogus 50% 'secondary disadvantaged' anchor are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 19:05:15 +01:00
TudorandClaude Fable 5 9773483221 fix(compare): date report cards with their own inspection date, never the legacy one
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 14:48:17 +01:00
TudorandClaude Fable 5 e00a1b38a8 feat(pipeline): carry the report-card inspection's own date; pick newest MI file in discovery
The MI file's report-card grade columns belong to the latest FULL
inspection (col 'Inspection start date'), but inspection_date maps to the
legacy OEIF graded/ungraded dates — so report cards were being dated with
pre-Nov-2025 inspections. Also discover_csv_url() returned matches[0],
the oldest (2017) link on the GOV.UK page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 14:44:51 +01:00
TudorandClaude Fable 5 026a7ab6aa fix(compare): render all-secondary comparisons — re-run phase detection after basket hydration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 14:42:09 +01:00
TudorandClaude Fable 5 a86a2be96c docs: plan for the five must-fix findings from the final expert review
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 14:39:11 +01:00
tudor d98e88f0b4 Merge pull request 'fix: preserve literal 'NULL' strings for primary key columns' (#49) from feature/ingest-independent-schools 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 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 44s
Reviewed-on: #49
2026-07-16 07:54:41 +00:00
Tudor 609bb923d9 fix: preserve literal 'NULL' strings for primary key columns
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 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 47s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 8s
2026-07-16 08:54:14 +01:00
tudor 4bfcd9ba9a Merge pull request 'fix: convert NaN/NULL to None and restore record properties structure in tap.py' (#48) from feature/ingest-independent-schools 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 1m22s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 41s
Reviewed-on: #48
2026-07-16 07:40:28 +00:00
Tudor 95f10bf352 fix: convert NaN/NULL to None and restore record properties structure in tap.py
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 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 47s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m46s
2026-07-16 08:29:59 +01:00
tudor 674470ceb6 Merge pull request 'feat: ingest independent schools in Ofsted tap and dbt staging' (#47) from feature/ingest-independent-schools 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 1m25s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 44s
Reviewed-on: #47
2026-07-15 22:26:39 +00:00
Tudor 8abff7a0a1 feat: ingest independent schools in Ofsted tap and dbt staging
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 47s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 10s
2026-07-15 23:21:40 +01:00
tudor 6f62c25f47 Merge pull request 'Pass phase state to compare sub-components to prevent phase metrics override by multi-phase schools' (#46) from fix/compare-expert-fixes 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 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 49s
Reviewed-on: #46
2026-07-15 16:38:49 +00:00
Tudor e74d3882ce Pass phase state to compare sub-components to prevent phase metrics override by multi-phase schools
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 9s
2026-07-15 17:38:02 +01:00
tudor 3fb3db1cc4 Merge pull request 'Fix Ofsted transitional inspections, phase tab exclusions, and FSM benchmark comparison' (#45) from fix/compare-expert-fixes into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 22s
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 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 42s
Reviewed-on: #45
2026-07-15 16:30:00 +00:00
Tudor b4b0249a06 Fix Ofsted transitional inspections, phase tab exclusions, and FSM benchmark comparison
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m9s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
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) Failing after 10s
2026-07-15 17:23:40 +01:00
tudor e39aef2935 Merge pull request 'fix(compare): sticky school bar hidden behind the site header' (#44) from fix/schoolbar-sticky-offset 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 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 39s
Reviewed-on: #44
2026-07-15 12:02:29 +00:00
TudorandClaude Fable 5 fef83b3bf2 fix(compare): sticky school bar hidden behind the site header
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m7s
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 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m3s
The Navigation header is position:sticky top:0 (z-index 1000). The school
bar was also sticky top:0 (z-index 10), so when scrolled it pinned at the
same top:0 *behind* the header — on mobile 57 of its 72px were covered,
leaving only a sliver, so you couldn't see which schools were selected.

Offset the bar's sticky top to the header height (65px desktop, 57px
mobile — the Navigation breakpoint is also 640px) so it pins just below.

e2e guard asserts the bar's sticky offset is at least the header height
(verified it fails against the pre-fix build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-15 12:32:36 +01:00
tudor cf458fe05c Merge pull request 'School name cutoff fix' (#43) from fix/compare-mockup-drift 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 50s
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: #43
2026-07-15 08:01:47 +00:00
Tudor f579630fab School name cutoff fix
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 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 9s
2026-07-15 08:57:21 +01:00
tudor 19b41b6999 Merge pull request 'fix(compare): match mockups — remove trends table + mobile measure-first cards' (#42) from fix/compare-mockup-drift 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 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 37s
Reviewed-on: #42
2026-07-15 07:17:08 +00:00
TudorandClaude Fable 5 66bc5523f6 fix(compare): mobile measure-first cards to match the mockup
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 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m57s
The grid sections (At a glance, Ofsted, Getting a place, Who goes there)
collapsed generically on mobile — grey label pills, full names wrapping
to 3 lines, no dots — making the page ~2x the mockup's height and
'significantly different' from the mobile design.

Each measure is now wrapped in a <Measure> that is display:contents on
desktop (so the label + cells still flow into the shared aligned grid,
unchanged) and a white card on mobile with compact [dot][short name]
[value] rows — matching the mobile mockup. The sticky school bar becomes
scrollable short-name pills on mobile too. Adds a shortName() util.

Desktop layout is unchanged (display:contents dissolves the wrapper).
Validated the card mechanism and real content shapes (report-card cell,
badges, %+chip rows) via static previews at both widths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-15 07:50:01 +01:00
TudorandClaude Fable 5 3cb72d0a0f fix(compare): remove the year-by-year data table from Explore trends
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 12s
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 39s
The mockup's Explore trends section is the measure picker + chart only —
no data table. Removes the table (and the now-unused progressBand / band
chip / formatMetricValue plumbing that only fed it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-15 07:29:24 +01:00
tudor b89fa47ec5 Merge pull request 'fix(compare): trends chart was squashed to ~150px — give it a real height' (#41) from fix/trends-chart-height 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 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 38s
Reviewed-on: #41
2026-07-14 22:51:04 +00:00
tudor 0c7ad0f309 Merge pull request 'test(e2e): fix flaky compare journey — select two same-phase schools' (#40) from fix/e2e-compare-samephase 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 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 37s
Reviewed-on: #40
2026-07-14 22:30:18 +00:00
TudorandClaude Fable 5 e4565e9f15 fix(compare): give the trends chart a real height (was squashed to ~150px)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
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 17s
ComparisonChart runs Chart.js with maintainAspectRatio:false, so it sizes
to its container's height — which must be definite. TrendsExplorer gave
.chartBox a min-height, which doesn't resolve the chart wrapper's
height:100%, so Chart.js fell back to its ~150px default: a squashed
8.6:1 sliver that didn't match the mockups. Set a definite height (420px
desktop, 360px mobile where the chips row sits above the canvas).

Verified on staging by patching the live height: canvas went from
1287x150 to 1287x392 (desktop) / 284 (mobile) — proper ~3:1 proportions
matching the mockup, with the England dashed line, COVID/2021-22 gap and
table all reading correctly.

An e2e guard asserts the trends canvas is taller than 220px so the
squash can't regress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 23:24:39 +01:00
TudorandClaude Fable 5 06e4898c30 test(e2e): pick two same-phase schools for the compare journey
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
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 47s
The compare page's phase tabs put all-through schools (which carry KS4
data) on the secondary tab, so comparing an all-through school with a
pure primary splits them across tabs and only the active tab renders its
link. The test picked the first two 'primary' search hits without
guaranteeing same phase, so it flaked whenever a search returned an
all-through school first (e.g. URN 137306). Now selects two pure-Primary
URNs via the API — deterministic and data-invariant.

Verified against staging: was a 15.6s timeout, now passes in ~1.8s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 23:12:23 +01:00
tudor a9611e21c3 Merge pull request 'perf(api): batch supplementary queries — /api/compare stops scaling per school' (#38) from perf/batch-supplementary into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
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) Failing after 1m8s
Reviewed-on: #38
2026-07-14 21:57:27 +00:00
tudor 0696518995 Merge pull request 'ci: speed up Frontend Typecheck + Tests (cache node_modules; cancel superseded runs)' (#39) from ci/frontend-checks-speedup 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 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 1m10s
Reviewed-on: #39
2026-07-14 21:57:08 +00:00
TudorandClaude Fable 5 090d5f7bec ci: speed up Frontend Typecheck + Tests; cancel superseded PR runs
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m34s
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 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The job does ~3s of real work (typecheck 1.4s + jest 1.3s) but installs
452 MB / 460 packages every run. Two changes:

- Cache nextjs-app/node_modules keyed on the lockfile hash (OS + node
  major pinned) and skip npm ci entirely on a hit — deps change rarely,
  so most PR pushes now do zero install. On miss, npm ci runs with
  --prefer-offline --no-audit --no-fund.
- Workflow-level concurrency with cancel-in-progress: a new commit (or an
  empty re-trigger) aborts the previous run instead of stacking a second
  full matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 22:54:32 +01:00
TudorandClaude Fable 5 315f1feede perf(api): batch supplementary queries — one per table, not five per school
PR Checks / Backend Smoke (pull_request) Canceled after 0s
PR Checks / Build Backend (no push) (pull_request) Canceled after 0s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 0s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
PR Checks / Frontend Typecheck + Tests (pull_request) Canceled after 8m16s
get_supplementary_data ran ~5 sequential DB round-trips per URN, so
/api/compare scaled at ~37ms/school (measured on staging: 1 school 155ms,
3 schools 220ms, 6 schools 340ms). get_supplementary_data_batch fetches
each table once with WHERE urn IN (...) and groups in Python, collapsing
5*N round-trips to a constant 5. get_supplementary_data is now a thin
wrapper so the detail endpoint is unchanged; the compare endpoint makes
one batched call. Each table degrades independently on failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 22:42:10 +01:00
tudor abc03a0dd3 Merge pull request 'fix(compare): blank page on refresh + remove dead per-page comparison fetch' (#37) from fix/compare-refresh-and-fetch 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 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 1m9s
Reviewed-on: #37
2026-07-14 21:39:46 +00:00
TudorandClaude Fable 5 43a2c4a6bc fix(compare): show SSR data on refresh; drop dead per-page comparison fetch
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m42s
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 9s
Refresh bug: on mount the basket is empty for a beat before it hydrates
from the URL. The fetch effect nulled comparisonData on that transient
empty urnKey, then the one-shot 'SSR covers it' skip suppressed the
refetch — leaving the page blank on reload. The effect is now gated on
isInitialized, never blanks on empty (the render already shows the empty
state when nothing is selected), and decides fetch-vs-skip by whether it
already holds each requested school's data (SSR or a prior fetch).

Perf: useComparison ran a useSWR('/api/compare') whose result nothing
consumed — dead weight that fired on every page (Navigation + Toast are
global) whenever the basket was non-empty, and duplicated ComparisonView's
own fetch on the compare page. Removed; the hook now exposes basket state
only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 22:34:20 +01:00
tudor 8e4ee64140 Merge pull request 'perf(compare): import-time KS4 national averages mart; no refetch on metric change' (#36) from perf/compare-loading into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 23s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 58s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m11s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m51s
Reviewed-on: #36
2026-07-14 20:04:45 +00:00
TudorandClaude Fable 5 d2dc78aeb5 ci: re-run PR checks (AI review job errored without posting findings)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
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 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 13:25:24 +01:00
TudorandClaude Fable 5 619e3a1189 perf(compare): fetch only on school-set changes; use SSR payload; parallel page fetches
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m46s
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 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 5m4s
- Metric changes no longer refire /api/compare (the data is already
  client-side; the picker is presentational) — the fetch effect depends
  only on the URN set, with URL sync split into its own effect.
- The initial client fetch is skipped when the SSR payload already covers
  the selected schools; national averages + benchmarks now arrive via SSR
  props so nothing is lost by skipping.
- page.tsx fetches comparison and metrics in parallel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 13:06:52 +01:00
TudorandClaude Fable 5 52f8994401 perf(api): persist KS4 national averages as a mart; stop per-request aggregation
fact_ks4_national_averages is computed once at dbt build time (covered by
the EES DAG's stg_ees_ks4+ selector). _national_averages_payload now reads
both national-averages marts instead of scanning the performance dataframe
per year on every /api/compare request (~250ms saved per call). Fallback
for the deploy-before-DAG window computes the latest year only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 13:05:03 +01:00
tudor 9990f540f7 Merge pull request 'feat(compare): parent-first compare screen — sections, England anchors, report cards, mobile-first' (#35) from feat/compare-frontend-rebuild into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 43s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m1s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m57s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 46s
Reviewed-on: #35
2026-07-14 07:26:21 +00:00
TudorandClaude Fable 5 6dd9b04b50 ci: re-run PR checks (AI review job errored without posting findings)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 32s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 07:07:56 +01:00
TudorandClaude Fable 5 d0e71e2cf0 feat(api): compare school_info carries GIAS facts for the community section
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
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) Failing after 11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 00:04:03 +01:00
TudorandClaude Fable 5 6138e2b2ee Merge main (PR #34) into compare frontend branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 00:04:03 +01:00
TudorandClaude Fable 5 2f85b9c647 test(e2e): compare journeys for the parent-first redesign
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m42s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 15s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 38s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 23:59:22 +01:00
TudorandClaude Fable 5 2155256177 feat(compare): parent-first compare screen assembly
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 23:58:00 +01:00
TudorandClaude Fable 5 519584f34b feat(compare): trends explorer with England line; gap-honest axis; series regression guard
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 23:54:58 +01:00
TudorandClaude Fable 5 2573cd2490 feat(compare): academics strips with England anchors and More measures
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 23:50:29 +01:00
TudorandClaude Fable 5 9f2260ce50 feat(compare): at-a-glance, Ofsted, admissions and community sections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 23:49:17 +01:00
tudor 4a8e798c64 Merge pull request 'feat(api): compare endpoint enrichment — supplementary blocks, national averages, benchmarks, report-card labels' (#34) from feat/compare-api-enrichment into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 24s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 57s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m5s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 4m49s
Reviewed-on: #34
2026-07-13 21:58:51 +00:00
TudorandClaude Fable 5 60cbc3f46d feat(compare): DotStrip with England-average anchor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 19:18:33 +01:00
TudorandClaude Fable 5 48ca042b08 feat(compare): comprehension logic (report cards, admissions, verdicts, strips)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 19:17:15 +01:00
TudorandClaude Fable 5 80f057ea5a feat(compare): types for enriched comparison payload
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 19:14:55 +01:00
TudorandClaude Fable 5 9447b80bc6 docs: compare mockups as frontend design source + rebuild plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 19:13:18 +01:00
TudorandClaude Fable 5 c0f31a5941 feat(api): compare endpoint carries supplementary blocks, national averages and benchmarks
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m45s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 51s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 37s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 18:48:28 +01:00
TudorandClaude Fable 5 cec7941b44 feat(api): computed state-school benchmarks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 18:45:30 +01:00
TudorandClaude Fable 5 dbaa15c099 feat(api): expose progress CIs, KS4 banding/gaps, admissions detail, report-card labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 18:44:29 +01:00
TudorandClaude Fable 5 b5b47ca135 feat(api): Ofsted report-card labels and provider-page URL
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 18:42:08 +01:00
TudorandClaude Fable 5 0c89b2c34e feat(api): map compare-foundation mart columns
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 18:40:55 +01:00
TudorandClaude Fable 5 4bf90b5f09 feat(pipeline): thread compare-foundation columns through fact_performance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 15:46:43 +01:00
TudorandClaude Fable 5 17b4498c80 docs: plan for compare API enrichment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 15:46:04 +01:00
tudor 3754947fd6 Merge pull request 'feat(pipeline): compare-screen data foundation — raw→marts promotions, national averages, Ofsted report cards' (#32) from feat/compare-data-foundation into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
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 1m16s
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: #32
2026-07-13 12:52:11 +00:00
tudor 9799ad9b43 Merge pull request 'ci: two-stage deploy — staging automatic, production behind a manual approval' (#33) from chore/staged-prod-promotion 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 56s
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 39s
Reviewed-on: #33
2026-07-13 12:30:19 +00:00
TudorandClaude Fable 5 d5cd0abfee ci: re-run PR checks (AI review job errored without posting findings)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
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 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 46s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 5m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 13:15:52 +01:00
TudorandClaude Fable 5 6877abedeb fix(ci): harden promote workflow — env-isolated untrusted input, main-ancestry check, concurrency guard
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
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 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s
Addresses the AI review findings on PR #33:
- severe: the workflow_dispatch sha input was interpolated directly into
  the run script (shell injection with REGISTRY_TOKEN + prod webhook in
  scope). It now reaches the shell only via env, is rejected if it
  starts with '-', and is resolved locally with git rev-parse.
- minor: the resolved sha must be a 40-hex ancestor of origin/main —
  non-main refs are refused explicitly instead of implicitly.
- minor: a prod-promotion concurrency group serialises promotions
  (cancel-in-progress: false).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 13:14:28 +01:00
TudorandClaude Fable 5 436ec6151b fix(pipeline): thread KS2 progress CI columns through the legacy union and lineage model
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
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 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 46s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 28s
The AI review gate caught that stg_ees_ks2's 7 new columns broke the
positional UNION ALL with stg_legacy_ks2 in int_ks2_with_lineage, and
that the lineage CTEs never emitted them (same class of bug fixed for
KS4 in 34a5de2). Legacy gets typed null placeholders at matching
positions; both lineage CTEs pass the columns through. 45/45 columns
verified name-identical in order across both union branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 08:42:40 +01:00
TudorandClaude Fable 5 2b563cc0bf docs: two-stage deploy model (staging auto, production manual)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
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 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 08:38:37 +01:00
TudorandClaude Fable 5 75e92dc7f5 ci: manual production promotion workflow with e2e-gate verification
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 08:36:25 +01:00
TudorandClaude Fable 5 7499e7f557 ci: stop deploy pipeline at staging; production promotion becomes manual
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 08:35:39 +01:00
TudorandClaude Fable 5 dd0ff7d0c2 docs: plan for staged production promotion
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 08:34:32 +01:00
TudorandClaude Fable 5 6f925abf6b fix(pipeline): harden banding against EES sentinels; diagnostic cleanups
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m40s
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 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 46s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m17s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-13 08:22:34 +01:00
TudorandClaude Fable 5 03518520f8 fix(pipeline): unknown safeguarding values parse to NULL, not "not met"
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-12 22:17:11 +01:00
TudorandClaude Fable 5 c2ed002118 docs(pipeline): record Task 7 report-card grade value sample + collision check
Evidence trail for the rc_* mapping in the prior commit: real value_counts()
over the 7 MI report-card columns, confirming the 5-value grade vocabulary
and that 'Achievement'/'Safeguarding standards' match by exact string only
(no legacy OEIF column accidentally consumed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-12 22:13:03 +01:00
TudorandClaude Fable 5 02084e427c feat(pipeline): extract Ofsted report-card judgements (rc_* columns)
Wires the tap TODO in stg_ofsted_inspections.sql: maps the 7 confirmed
report-card MI columns (Safeguarding standards, Inclusion, Curriculum
and teaching, Achievement, Attendance and behaviour, Personal
development and wellbeing, Leadership and governance) into rc_*
fields, parsed via the new parse_report_card_grade macro against
real sampled grade values (Exceptional/Strong standard/Expected
standard/Needs attention/Urgent improvement). rc_safeguarding_met
becomes boolean from Met/Not met. rc_early_years/rc_sixth_form have
no MI column yet and are intentionally omitted from COLUMN_PRIORITY,
staying NULL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-12 22:12:33 +01:00
TudorandClaude Fable 5 bee63a7836 docs(spec): 2021/22 school-level KS2 is a permanent DfE source gap, not a pipeline task
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-12 22:10:05 +01:00
TudorandClaude Fable 5 fc21783298 chore(pipeline): verify 2021/22 legacy KS2 archive compatibility
DfE never published school-level KS2 2021/22 data publicly (confirmed via
EES release notes and by walking the Compare School Performance download
wizard, which has no ks2 checkbox for 2021-2022, same as the COVID-cancelled
2020-2021 year). No archive exists to verify column headers against or
upload to the filebrowser; Task 6 is blocked at the source-data level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-12 22:08:36 +01:00
TudorandClaude Fable 5 ccd8e73fe8 fix(pipeline): include 2015/16 national averages
Widen the year filter in stg_ees_ks2_national.sql from >= 201617 to
>= 201516 so the England national-averages line no longer starts a
year late; the catalogue CSV has a real, comparable 201516 row (2015/16
was the first year of the current expected-standard tests, so it's the
correct floor).

GPS/science/scaled-score national columns confirmed present at source
with correct mapping; prod NULLs are stale raw data, backfilled by the
next extract run. No _KS2_NATIONAL_COL_MAP change accompanies this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-12 22:02:28 +01:00
TudorandClaude Fable 5 5f1b6adb44 fix(pipeline): align SEN column order across KS4 union branches
int_ks4_with_lineage.sql unions stg_ees_ks4 and stg_legacy_ks4 via
`select *`, which PostgreSQL aligns positionally. stg_legacy_ks4 listed
sen_support_pct before sen_ehcp_pct while stg_ees_ks4 lists sen_ehcp_pct
before sen_support_pct, swapping the two values for legacy-sourced rows
in marts.fact_ks4_performance. Reordered stg_legacy_ks4's final select
to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-12 22:00:15 +01:00
TudorandClaude Fable 5 34a5de2687 feat(pipeline): Progress 8 banding and KS4 disadvantage gaps in marts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-12 21:55:34 +01:00
Tudor af43b291e7 feat(pipeline): KS2 progress confidence intervals and writing working-towards 2026-07-12 21:34:59 +01:00
Tudor 5a94f470e1 feat(pipeline): admissions preference breakdown and cross-LA demand in marts 2026-07-12 21:30:50 +01:00
Tudor 0fe1ea0d6a chore(pipeline): diagnostic for compare-screen data gaps 2026-07-12 21:25:00 +01:00
TudorandClaude Fable 5 297bdbd12e docs: compare-screen redesign spec, expert review, and data-foundation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-12 21:19:53 +01:00
tudor 58e90fef61 Merge pull request 'fix(api): blank-name GIAS sentinel codes map to empty string, not Unknown(n)' (#27) from fix/gias-blank-name-codes into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 20s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 54s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m5s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 41s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #27
2026-07-09 21:21:08 +00:00
TudorandClaude Fable 5 3710529e49 fix(api): map blank-name GIAS sentinel codes to empty string, not Unknown
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m40s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 54s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 37s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m35s
ReligiousCharacter 99 (~4k schools) and AdmissionsPolicy 9 (~5.6k) carry a
code with a blank name in the GIAS CSV; the generator skipped them so they
hit the Unknown(<code>) path — wrongly triggering the Faith-priority tag
and polluting filters. Blank-only codes now map to "" (byte-identical to
the old name pipeline); accepted_values lists extended to match the seed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:04:53 +01:00
tudor 159207c6f5 Merge pull request 'fix(pipeline): add the missing backend cache-invalidation step to the data DAGs' (#26) from fix/daily-dag-cache-invalidation into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 51s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m3s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 43s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #26
2026-07-09 20:41:42 +00:00
TudorandClaude Fable 5 d677b54533 fix(pipeline): cache invalidation for IDACI DAG too; curl timeouts
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 35s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m40s
Addresses AI-review findings: the annual IDACI DAG also rebuilds a mart
(fact_deprivation) and needs the reload; curl gets connect/max timeouts
so an unreachable backend fails fast instead of hanging the task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:26:45 +01:00
TudorandClaude Fable 5 c353e36072 fix(pipeline): actually invalidate the backend cache after data rebuilds
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m40s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 51s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 1m20s
The daily/monthly/annual DAG docstring promised an Invalidate Cache step
that never existed — after a marts rebuild the backend kept serving its
startup-cached (possibly empty) DataFrame until a container restart.
Add a POST /api/admin/reload task at the end of each pipeline DAG,
mirroring the sitemap DAG's admin-call pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:12:01 +01:00
tudor d9223a6d6e Merge pull request 'fix(api): legacy name-column fallback when marts predate the GIAS code migration' (#25) from fix/gias-legacy-fallback into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 19s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 54s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 4m45s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #25
2026-07-09 19:13:55 +00:00
TudorandClaude Fable 5 74ca76d150 fix(api): match missing-column fallbacks on the DBAPI error, not the statement
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 53s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m41s
str(ProgrammingError) embeds the full SQL, which contains every column
name — the substring check matched any error and could take the wrong
retry branch. Parse the missing column from exc.orig instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:29:58 +01:00
TudorandClaude Fable 5 4b75152ee0 fix(api): fall back to legacy name-column query when marts predate code migration
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
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) Failing after 3m8s
Closes the deploy window flagged by CI review — the backend now works
against both the old (name) and new (code) mart schemas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:48:06 +01:00
tudor 84dfc6c1bb Merge pull request 'feat: GIAS classification fields stored as codes, translated in code' (#24) from feat/gias-code-dictionaries into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 19s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 52s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m16s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 4m47s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #24
2026-07-09 13:32:43 +00:00
TudorandClaude Fable 5 c26755750d docs: mark GIAS code dictionaries spec implemented
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 54s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m58s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:12:52 +01:00
TudorandClaude Fable 5 254a19eb42 fix(pipeline): run gias_code_names seed + drift test in the daily DAG
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:11:31 +01:00
TudorandClaude Fable 5 4f6b2b0edc feat(pipeline): typesense sync translates GIAS codes before indexing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:51:40 +01:00
TudorandClaude Fable 5 f1a013ec01 feat(api): translate GIAS codes to names at the query boundary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:48:53 +01:00
TudorandClaude Fable 5 fa6c929a3a feat(pipeline): dim_school/dim_location store GIAS codes; seed drift test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:45:15 +01:00
TudorandClaude Fable 5 d898e6279b feat(pipeline): ingest GIAS code columns; staging exposes codes not names
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:41:52 +01:00
TudorandClaude Fable 5 e188c2ff4b feat: GIAS code->name dictionaries generated from live bulk CSV
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:38:19 +01:00
TudorandClaude Fable 5 08bd86db05 docs: implementation plan for GIAS code dictionaries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:10:26 +01:00
TudorandClaude Fable 5 1ae5762a0a docs: design spec for GIAS code dictionaries (codes in marts, names in code)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:55:53 +01:00
tudor bc87e56545 Merge pull request 'fix(ui): shorten proposed-to-close notice copy' (#23) from fix/proposed-to-close-copy into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 48s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 38s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #23
2026-07-08 21:52:56 +00:00
TudorandClaude Fable 5 4522cbf645 fix(ui): shorten proposed-to-close notice copy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
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 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:51:32 +01:00
tudor 7370712888 Merge pull request 'feat: include and mark 'Open, but proposed to close' schools' (#22) from feat/proposed-to-close-schools into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 20s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 55s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m10s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 41s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 10s
Reviewed-on: #22
2026-07-08 21:23:23 +00:00
TudorandClaude Fable 5 45ab479062 feat(ui): mark proposed-to-close schools in listings and detail pages
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m46s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 21s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 35s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m1s
Amber tag in listing rows (option A) and a slim notice strip under the
detail-page header (option E): proposed for closure, formal process not
necessarily started, check with the local authority before applying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:05:55 +01:00
TudorandClaude Fable 5 6f602f4a9e feat(api): expose GIAS establishment status on school payloads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:05:55 +01:00
TudorandClaude Fable 5 de81e9cdbd feat(pipeline): include 'Open, but proposed to close' schools in dims
These schools are still operating and publish results; they drop out
automatically when GIAS flips them to Closed since marts fully rebuild
each run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 21:54:08 +01:00
tudor 45c68b60b4 Merge pull request 'feat: drive sixth-form separation from GIAS OfficialSixthForm flag' (#21) from feat/gias-sixth-form-flag into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 20s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 50s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m23s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 0s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 44s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #21
2026-07-07 13:48:12 +00:00
TudorandClaude Fable 5 f1388ff5bd fix(pipeline): normalize GIAS OfficialSixthForm comparison with lower(trim())
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
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 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 48s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m19s
Matches the phase derivation's guard against casing/whitespace variants in
raw GIAS data; an unmatched variant previously fell through silently to the
statutory-age fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:05:10 +01:00
TudorandClaude Fable 5 4d226fd616 test: drop unused fake exception helper
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
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 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 53s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m17s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:36:01 +01:00
TudorandClaude Fable 5 a524cdc591 fix(api): survive missing has_sixth_form column and numpy bool serialization
- data_loader.load_school_data_as_dataframe now catches a ProgrammingError
  whose message mentions has_sixth_form (psycopg2 UndefinedColumn) and
  retries with a NULL-AS-has_sixth_form query variant, so the API keeps
  serving data (and the app.py column-fallback branch stays reachable)
  even before the nightly pipeline has rebuilt marts.dim_school.
- utils.convert_to_native now handles numpy.bool_ so GET /api/schools/{urn}
  doesn't 500 once has_sixth_form is a populated bool-dtype column.
- Update the now-stale comment on the app.py age-range fallback branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:33:25 +01:00
TudorandClaude Fable 5 3fcb1340d4 docs: mark sixth-form flag pipeline change implemented
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:43:08 +01:00
TudorandClaude Fable 5 0934c8f38c feat(ui): sixth-form badge, note and filter labels use GIAS flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:40:42 +01:00
TudorandClaude Fable 5 1d149ffc48 feat(api): drive has_sixth_form filter and payloads from GIAS flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:36:39 +01:00
TudorandClaude Fable 5 d11faefebd feat(pipeline): derive dim_school.has_sixth_form from GIAS flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:30:39 +01:00
TudorandClaude Fable 5 3b35849bb3 feat(pipeline): ingest GIAS OfficialSixthForm into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:28:08 +01:00
TudorandClaude Fable 5 87f4c6dd40 docs: implementation plan for GIAS sixth-form flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:21:02 +01:00
TudorandClaude Fable 5 0309b27c84 docs: exam results phase taxonomy and sixth-form separation spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:02:21 +01:00
tudor 85484a80c4 Merge pull request 'fix(api): school detail 500s for schools with no performance rows' (#20) from fix/school-detail-nan-500 into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 41s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 47s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 39s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #20
2026-07-07 08:56:23 +00:00
TudorandClaude Opus 4.8 536832a524 chore: drop committed .pyc files, ignore __pycache__ everywhere
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 29s
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 1m54s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:37:17 +01:00
TudorandClaude Opus 4.8 87642b7b06 fix(api): serialize schools that have no performance rows
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 30s
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 2m0s
Schools without KS2/KS4 results (special post-16 institutions, sixth-form
centres, PRUs, new schools) come back from the marts LEFT JOIN with NaN in
every numeric column. school_info passed those raw pandas values straight
into JSONResponse, which renders with allow_nan=False, so the detail
endpoint 500d and the frontend turned that into a 404 on every such SEO
landing page.

Run school_info values through convert_to_native (the same treatment
yearly_data already gets), add backend unit tests plus a pytest step in PR
checks, and an e2e journey that finds a results-less school via the search
API and asserts its page renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:22:54 +01:00
tudor 929748d014 Merge pull request 'feat(home): move "use my location" beside the hero search box' (#19) from feat/near-me-by-search into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 51s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 38s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #19
2026-07-06 17:56:58 +00:00
TudorandClaude Opus 4.8 4e8df006d7 feat(home): move "use my location" beside the hero search box
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m40s
PR Checks / Backend Smoke (pull_request) Successful in 5s
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 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m19s
The geolocation shortcut lived in the discovery strip below the results,
away from the search. Move it directly under the hero search input, paired
with the postcode hint, so the two ways to find nearby schools ("type a
postcode" / "use my location") read as one idea and are visible at first
glance.

- FilterBar gains optional onNearMe/geoState/geoError props and renders the
  teal "Use my location" pill (with spinner + error) in hero mode; the
  geolocation flow itself still lives in HomeView.
- Remove the now-duplicate near-me button and its dead CSS from the
  discovery section.
- Refresh the search hint copy to pair with the button.
- e2e: assert the "use my location" shortcut renders in the hero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:01:45 +01:00
tudor 1f8284adfc Merge pull request 'fix(map): results map fullscreen falls back to an overlay on iOS' (#18) from fix/results-map-ios-fullscreen into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 48s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 38s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 8s
Reviewed-on: #18
2026-07-06 13:33:16 +00:00
TudorandClaude Fable 5 b2dc4d0779 fix(map): results map fullscreen falls back to an overlay on iOS
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 5s
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 1m4s
The results-view map's fullscreen button called requestFullscreen(),
which iOS Safari doesn't implement (fullscreen is video-only there), so
tapping it did nothing on iPhones — the same gap already fixed for the
school hero map.

When the Fullscreen API is missing or its promise rejects, fall back to
a fixed-position overlay (.fsFallback, z-index 5000) driven by state,
locking body scroll while open. Leaflet re-measures on window resize, so
dispatch a resize when fullscreen toggles (the CSS overlay fires none) or
the map would fill only part of the screen. Native fullscreen is
unchanged.

New e2e journey deletes Element.requestFullscreen on a mobile viewport,
opens the results map fullscreen, and asserts the exit control appears
then releases; it fails against current production, reproducing the bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:09:37 +01:00
tudor 1cdcd85e41 Merge pull request 'fix(search): stop the mobile sort dropdown overflowing the viewport' (#17) from fix/mobile-sort-select-overflow into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 51s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 36s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #17
2026-07-06 12:46:48 +00:00
TudorandClaude Fable 5 a00cbe9161 fix(search): stop the mobile sort dropdown overflowing the viewport
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 5s
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 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m3s
On a location search the results header shows the view toggle and the
sort <select> side by side. The select sizes to its widest option
('Highest Reading, Writing & Maths %', ~273px), so on a phone its right
edge ran ~46px past the viewport and was clipped off-screen.

On mobile let the select flex into the remaining space with min-width:0
so its label truncates instead of overflowing, and keep the view toggle
from shrinking. Verified live at 390px: the select now sits fully within
the viewport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:23:29 +01:00
tudor 64121592fd Merge pull request 'feat(compare): lay mobile chart chips two per row' (#16) from feat/compare-chips-two-per-row into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 50s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 38s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #16
2026-07-06 12:16:26 +00:00
TudorandClaude Fable 5 6828f6cd44 feat(compare): lay mobile chart chips two per row
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m43s
PR Checks / Backend Smoke (pull_request) Successful in 5s
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 1m50s
The mobile chart legend stacked one school chip per line, so up to five
schools pushed the chart down and left the plot cramped. Switch the chip
row to a two-column grid; each chip fills its column and truncates its
name with an ellipsis (full names remain on the school cards and in the
tooltip). Five schools now take three rows instead of five, giving the
chart noticeably more height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:17:41 +01:00
tudor 331ae8d89f Merge pull request 'fix(e2e): compare-chips test must compare schools in one phase' (#15) from fix/e2e-compare-chips-phase into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 53s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 38s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #15
2026-07-06 11:01:22 +00:00
TudorandClaude Fable 5 3adea73ee0 fix(e2e): compare-chips test must use schools in one phase
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 37s
The test picked the first two /school/ links from a 'primary' search and
asserted exactly two mobile chips. But a 'primary' search can return
all-through schools (e.g. 'Hessle High School and Penshurst Primary')
that classify as secondary, so the two picks can split across phases —
the active phase then holds one school and the chips are correctly gated
out (they need ≥2 in the active phase), while the canvas still shows one
line. That's a test artefact, not a bug.

Pick three schools instead: across two phases the auto-selected majority
phase always holds ≥2, so the chip legend is guaranteed. Assert ≥2 chips
(the majority may be 2 or 3). Verified against staging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:42:11 +01:00
tudor 47335fcda0 Merge pull request 'fix(frontend): proxy /api and /sitemap.xml at runtime, not via baked rewrites' (#14) from fix/runtime-api-proxy into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 48s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 52s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #14
2026-07-06 10:11:39 +00:00
TudorandClaude Fable 5 95a5783da1 fix(frontend): proxy /api and /sitemap.xml at runtime, not via baked rewrites
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
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) Successful in 3m13s
next.config.js rewrites() bakes its destination into the build
(routes-manifest.json), capturing FASTAPI_URL at build time. Because one
frontend image is promoted staging->prod, the baked backend host forced
every environment to name the backend service identically; staging names
it 'backend_stg', so the browser's /api/* calls proxied to the baked
'http://backend' and failed with getaddrinfo ENOTFOUND backend. (SSR was
unaffected because lib/api.ts reads FASTAPI_URL at runtime.)

Replace the rewrites with route handlers that read FASTAPI_URL per
request:
- app/api/[...path]/route.ts — transparent proxy for all methods, streams
  the response, strips hop-by-hop headers, and returns 502 on upstream
  failure instead of crashing.
- app/sitemap.xml/route.ts — proxies the backend sitemap (robots.ts points
  crawlers here).

The same promoted image now adapts to whatever the backend is called in
each environment. Verified: production build succeeds with /api/[...path]
and /sitemap.xml as dynamic routes and an empty rewrites manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:00:42 +01:00
tudor 5c39131b50 Merge pull request 'chore: remove the Ofsted Parent View feature end to end' (#13) from chore/remove-parent-view into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 20s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 47s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m14s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m7s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #13
2026-07-06 08:31:00 +00:00
TudorandClaude Fable 5 95081d38bd chore: remove the Ofsted Parent View feature end to end
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 45s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m35s
Removes the 'What Parents Say' section and all supporting elements:

Frontend:
- Drop the OfstedParentView type, the parent_view field, the survey
  section and the 'X% would recommend' callouts in the primary and
  secondary detail views, the Parents nav item, and the parent-view CSS.

Backend:
- Remove the FactParentView model, its loading in data_loader, and
  parent_view from the school-details API response.
- Bump SCHEMA_VERSION to 6 and add an idempotent drop step
  (DROP TABLE IF EXISTS marts.fact_parent_view) to the CLI migration;
  add scripts/sql/drop_fact_parent_view.sql to apply directly to the
  dbt-owned marts DBs on staging and prod.

Pipeline:
- Delete the stg_parent_view + fact_parent_view dbt models and their
  source/schema entries, the tap-uk-parent-view Meltano extractor, and
  the monthly Parent View DAG; drop it from the Dockerfile and the
  staging bootstrap docs.

The rest of dbt (which builds every mart the app reads) is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:01:26 +01:00
tudor 694b6013b3 Merge pull request 'fix(compare): keep chart data when a client refetch fails' (#12) from fix/compare-chart-refetch-resilience into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 16s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 47s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m9s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #12
2026-07-06 07:27:03 +00:00
TudorandClaude Fable 5 9f8dba227c fix(compare): keep chart data when a client refetch fails
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 5s
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 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m9s
The compare view refetches /api/compare on the client after SSR; on any
failure the catch nulled comparisonData, destroying the working
SSR-provided chart. A transient error (or staging's broken external /api
proxy) should not blank a comparison the user is already viewing — keep
the existing data instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:08:22 +01:00
tudor 18cd805c6c Merge pull request 'feat(compare): readable comparison chart on mobile' (#11) from feat/compare-chart-mobile-readability into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 49s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m6s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #11
2026-07-05 21:26:01 +00:00
TudorandClaude Fable 5 22769b6295 feat(compare): readable comparison chart on mobile
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 5s
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 2m49s
The compare chart squashed clustered schools into a thin band (y pinned
0-100) under an in-chart title + per-school legend that ate ~40% of a
300px card, leaving converging lines indistinguishable on phones.

- Auto-fit the y-axis to the data on all viewports (computeYBounds in
  lib/utils: padded + min-span for percentages, symmetric around 0 for
  progress, fitted for scores; negative pct-named trend metrics are not
  zero-clamped).
- Distinct point style per school (circle/triangle/rect/rectRot/star)
  as secondary encoding for convergence and colour-blindness.
- Mobile: drop in-chart title/legend/axis titles; add a chip row (colour
  dot + name) that doubles as tap-to-focus — highlights one school's
  line and dims the rest. Chart card 300px -> 340px, nearly all plot.
- Fix a latent colour mismatch: datasets were built from Object.entries
  whose integer-like URN keys enumerate in ascending numeric order,
  desyncing line colours from card colours; the chart now receives the
  ordered school list.
- Union years across schools instead of taking the first school's.
- Extract PerformanceChart's matchMedia pattern into hooks/useIsMobile.

Unit tests for metricKind/computeYBounds; e2e journey covers the mobile
chips and focus toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:02:26 +01:00
tudor 90f2a02e75 Merge pull request 'fix(school): make hero map fullscreen work on iOS Safari' (#10) from fix/hero-map-ios-fullscreen into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 52s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 34s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 8s
Reviewed-on: #10
2026-07-05 20:52:32 +00:00
148 changed files with 15479 additions and 2079 deletions
+3 -46
View File
@@ -1,4 +1,4 @@
name: Deploy (staging -> E2E gate -> production) name: Stage (build -> staging -> E2E gate)
on: on:
push: push:
@@ -193,48 +193,5 @@ jobs:
env: env:
BASE_URL: ${{ secrets.STAGING_BASE_URL }} BASE_URL: ${{ secrets.STAGING_BASE_URL }}
promote-prod: # Production deployment is a second, manual approval: see promote.yml
name: Promote to Production # ("Promote to Production (manual)") and docs/DEPLOY.md.
runs-on: ubuntu-latest
needs: [e2e-staging]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Retag verified images as prod
run: |
SHORT_SHA="sha-$(echo "${{ gitea.sha }}" | cut -c1-7)"
for IMAGE in \
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
# Keep a rollback pointer before moving :prod
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
done
- name: Trigger production stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
- name: Wait for production to become healthy
run: |
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
echo "Production is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Production did not become healthy in time" >&2
exit 1
env:
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
+24 -4
View File
@@ -5,6 +5,13 @@ on:
branches: branches:
- main - main
# Cancel superseded runs: pushing a new commit to a PR (or an empty
# re-trigger) aborts the previous still-running checks instead of running
# a second full matrix alongside them.
concurrency:
group: pr-checks-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
env: env:
REGISTRY: privaterepo.sitaru.org REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
@@ -23,12 +30,22 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: npm
cache-dependency-path: nextjs-app/package-lock.json # Cache the resolved node_modules (452 MB / 460 packages) keyed on the
# lockfile. On a hit — the common case, since deps change rarely — the
# whole `npm ci` step is skipped, not just its download phase. The key
# pins OS + node major so we never restore incompatible native binaries.
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: nextjs-app/node_modules
key: nextjs-node-modules-${{ runner.os }}-node22-${{ hashFiles('nextjs-app/package-lock.json') }}
- name: Install dependencies - name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
working-directory: nextjs-app working-directory: nextjs-app
run: npm ci run: npm ci --prefer-offline --no-audit --no-fund
- name: Typecheck - name: Typecheck
working-directory: nextjs-app working-directory: nextjs-app
@@ -51,11 +68,14 @@ jobs:
python-version: "3.12" python-version: "3.12"
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt pytest "httpx<0.28"
- name: Import smoke test - name: Import smoke test
run: python -c "from backend.app import app; print('backend imports OK')" run: python -c "from backend.app import app; print('backend imports OK')"
- name: Backend unit tests
run: python -m pytest backend/tests -q
build-backend: build-backend:
name: Build Backend (no push) name: Build Backend (no push)
runs-on: ubuntu-latest runs-on: ubuntu-latest
+129
View File
@@ -0,0 +1,129 @@
name: Promote to Production (manual)
# Second approval gate of the deploy model: run this workflow from the
# Actions UI after testing the feature on staging. It refuses commits
# whose staging E2E gate is not green. See docs/DEPLOY.md.
on:
workflow_dispatch:
inputs:
sha:
description: >-
Commit SHA on main to promote (full or >=7 chars).
Leave empty to promote the latest main commit.
required: false
default: ""
# Only one promotion at a time; never cancel an in-flight promotion.
concurrency:
group: prod-promotion
cancel-in-progress: false
env:
REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs:
promote-prod:
name: Promote approved commit to Production
runs-on: ubuntu-latest
steps:
- name: Checkout repository (full history for ancestry check)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve and validate target SHA
id: resolve
# SECURITY: the dispatch input is untrusted — it reaches the shell
# only via env (never spliced into `run:` with ${{ }}) and is only
# used as a quoted argument. The resolved value is validated as a
# 40-hex sha and required to be an ancestor of main before any
# later step interpolates it.
env:
SHA_INPUT: ${{ gitea.event.inputs.sha }}
run: |
set -euo pipefail
case "$SHA_INPUT" in
-*) echo "REFUSED: SHA input may not start with '-'." >&2; exit 1 ;;
esac
if [ -z "$SHA_INPUT" ]; then
SHA_INPUT="$(git rev-parse origin/main)"
fi
FULL_SHA=$(git rev-parse --verify --quiet "${SHA_INPUT}^{commit}") || {
echo "REFUSED: not a commit in this repository." >&2
exit 1
}
echo "$FULL_SHA" | grep -Eq '^[0-9a-f]{40}$'
if ! git merge-base --is-ancestor "$FULL_SHA" origin/main; then
echo "REFUSED: $FULL_SHA is not on main — only main commits are promotable." >&2
exit 1
fi
SHORT_SHA="sha-$(echo "$FULL_SHA" | cut -c1-7)"
echo "full=$FULL_SHA" >> "$GITHUB_OUTPUT"
echo "short=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
- 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: |
STATUS_JSON=$(curl -fsS \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
echo "$STATUS_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
ok = [s for s in d.get('statuses', [])
if 'E2E Journeys against Staging' in s.get('context', '')
and s.get('status') == 'success']
if not ok:
print('REFUSED: no successful \"E2E Journeys against Staging\" status on this commit.')
print('Contexts found:', [s.get('context') for s in d.get('statuses', [])])
sys.exit(1)
print('E2E gate verified green for this commit.')
"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Retag approved images as prod (keeping rollback pointer)
run: |
SHORT_SHA="${{ steps.resolve.outputs.short }}"
for IMAGE in \
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
# Keep a rollback pointer before moving :prod
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
done
- name: Trigger production stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
- name: Wait for production to become healthy
run: |
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
echo "Production is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Production did not become healthy in time" >&2
exit 1
env:
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
+6 -1
View File
@@ -1,2 +1,7 @@
venv venv
backend/__pycache__ __pycache__/
# dbt local build artifacts (embed absolute paths + anonymous-usage UUID)
pipeline/transform/target/
pipeline/transform/logs/
pipeline/transform/.user.yml
+173 -80
View File
@@ -25,15 +25,17 @@ import asyncio
from .config import settings from .config import settings
from .data_loader import ( from .data_loader import (
clear_cache, clear_cache,
compute_benchmarks,
load_school_data, load_school_data,
load_latest_school_data, load_latest_school_data,
geocode_single_postcode, geocode_single_postcode,
get_supplementary_data, get_supplementary_data,
get_supplementary_data_batch,
search_schools_typesense, search_schools_typesense,
) )
from .data_loader import get_data_info as get_db_info from .data_loader import get_data_info as get_db_info
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
from .utils import clean_for_json from .utils import clean_for_json, convert_to_native
# Values to exclude from filter dropdowns (empty strings, non-applicable labels) # Values to exclude from filter dropdowns (empty strings, non-applicable labels)
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"} EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
@@ -416,10 +418,17 @@ async def get_schools(
df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()] df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
if admissions_policy: if admissions_policy:
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()] df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
if has_sixth_form == "yes": # GIAS OfficialSixthForm flag (dim_school.has_sixth_form). NULL (flag not
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)] # yet populated by the pipeline) is treated as "no sixth form".
elif has_sixth_form == "no": if has_sixth_form in ("yes", "no"):
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)] if "has_sixth_form" in df_latest.columns:
flag = df_latest["has_sixth_form"].eq(True)
else: # Defensive fallback only — data_loader now always synthesizes
# has_sixth_form as NULL when the DB predates the pipeline re-run,
# so this branch shouldn't normally trigger. Falls back to age
# range if the column is somehow absent anyway.
flag = df_latest["age_range"].str.contains("18", na=False)
df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]
# Include key result metrics for display on cards # Include key result metrics for display on cards
location_cols = ["latitude", "longitude"] location_cols = ["latitude", "longitude"]
@@ -572,7 +581,7 @@ async def get_school_details(request: Request, urn: int):
# Get latest info for the school # Get latest info for the school
latest = school_data.iloc[-1] latest = school_data.iloc[-1]
# Fetch supplementary data (Ofsted, Parent View, admissions, etc.) # Fetch supplementary data (Ofsted, admissions, etc.)
from .database import SessionLocal from .database import SessionLocal
supplementary = {} supplementary = {}
try: try:
@@ -582,8 +591,13 @@ async def get_school_details(request: Request, urn: int):
except Exception: except Exception:
pass pass
return { # Schools with no performance rows (post-16 institutions, PRUs, new
"school_info": { # schools) carry NaN in every LEFT-JOINed numeric column; NaN reaching
# JSONResponse raises ValueError, so school_info needs the same
# conversion yearly_data gets from clean_for_json.
school_info = {
k: convert_to_native(v)
for k, v in {
"urn": urn, "urn": urn,
"school_name": latest.get("school_name", ""), "school_name": latest.get("school_name", ""),
"local_authority": latest.get("local_authority", ""), "local_authority": latest.get("local_authority", ""),
@@ -591,6 +605,8 @@ async def get_school_details(request: Request, urn: int):
"address": latest.get("address", ""), "address": latest.get("address", ""),
"religious_denomination": latest.get("religious_denomination", ""), "religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""), "age_range": latest.get("age_range", ""),
"has_sixth_form": latest.get("has_sixth_form"),
"status": latest.get("status"),
"latitude": latest.get("latitude"), "latitude": latest.get("latitude"),
"longitude": latest.get("longitude"), "longitude": latest.get("longitude"),
"phase": latest.get("phase"), "phase": latest.get("phase"),
@@ -601,11 +617,14 @@ async def get_school_details(request: Request, urn: int):
"total_pupils": latest.get("gias_total_pupils"), "total_pupils": latest.get("gias_total_pupils"),
"trust_name": latest.get("trust_name"), "trust_name": latest.get("trust_name"),
"gender": latest.get("gender"), "gender": latest.get("gender"),
}, }.items()
}
return {
"school_info": school_info,
"yearly_data": clean_for_json(school_data), "yearly_data": clean_for_json(school_data),
# Supplementary data (null if not yet populated by Kestra) # Supplementary data (null if not yet populated by Kestra)
"ofsted": supplementary.get("ofsted"), "ofsted": supplementary.get("ofsted"),
"parent_view": supplementary.get("parent_view"),
"census": supplementary.get("census"), "census": supplementary.get("census"),
"admissions": supplementary.get("admissions"), "admissions": supplementary.get("admissions"),
"admissions_history": supplementary.get("admissions_history") or [], "admissions_history": supplementary.get("admissions_history") or [],
@@ -645,6 +664,62 @@ async def compare_schools(
if comparison_data.empty: if comparison_data.empty:
raise HTTPException(status_code=404, detail="No schools found") raise HTTPException(status_code=404, detail="No schools found")
# One session for all schools' supplementary blocks; failures degrade
# to empty blocks rather than failing a working comparison (mirrors
# the detail endpoint's defensive pattern).
from . import database
_EMPTY_SUPPLEMENTARY = {
"ofsted": None,
"census": None,
"admissions": None,
"admissions_history": [],
"deprivation": None,
}
supplementary_by_urn: dict = {}
census_benchmarks = None
db = None
try:
db = database.SessionLocal()
# One query per table for all schools, not ~5 queries per school.
batch = get_supplementary_data_batch(db, urn_list)
for urn in urn_list:
supp = batch.get(urn, {})
supplementary_by_urn[urn] = {
key: supp.get(key, default)
for key, default in _EMPTY_SUPPLEMENTARY.items()
}
# Import-time census context benchmarks (fact_census_benchmarks);
# absent mart → None, and compute_benchmarks leaves those fields null.
try:
from .models import CensusBenchmark
rows = db.query(CensusBenchmark).all()
by_phase = {
r.phase: {
"year": r.year,
"fsm_pct": r.fsm_pct,
"eal_pct": r.eal_pct,
"median_pupils": r.median_pupils,
}
for r in rows
if getattr(r, "phase", None) in ("primary", "secondary")
}
if by_phase:
census_benchmarks = by_phase
except Exception:
# Missing mart (or a stubbed session in tests) must never break
# the compare payload — and not every session has rollback().
try:
db.rollback()
except Exception:
pass
except Exception:
supplementary_by_urn = {}
finally:
if db is not None:
db.close()
result = {} result = {}
for urn in urn_list: for urn in urn_list:
school_data = comparison_data[comparison_data["urn"] == urn].sort_values("year") school_data = comparison_data[comparison_data["urn"] == urn].sort_values("year")
@@ -660,11 +735,30 @@ async def compare_schools(
"phase": latest.get("phase", ""), "phase": latest.get("phase", ""),
"attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None, "attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None,
"rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None, "rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None,
# GIAS facts the compare "Who goes there" section needs
# (same fields the detail endpoint exposes)
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
"age_range": convert_to_native(latest.get("age_range")),
"gender": convert_to_native(latest.get("gender")),
# Needed by the admissions "What this means" copy: selective
# schools get entrance-test framing, never the distance template.
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
"capacity": convert_to_native(latest.get("capacity")),
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
"trust_name": convert_to_native(latest.get("trust_name")),
}, },
"yearly_data": clean_for_json(school_data), "yearly_data": clean_for_json(school_data),
**supplementary_by_urn.get(urn, dict(_EMPTY_SUPPLEMENTARY)),
} }
return {"comparison": result} return {
"comparison": result,
# Official DfE anchors + computed state-school benchmarks so the
# compare UI can label provenance correctly (spec §8.6).
"national_averages": _national_averages_payload(df),
"benchmarks": compute_benchmarks(df, census_benchmarks=census_benchmarks),
}
@app.get("/api/filters") @app.get("/api/filters")
@@ -710,96 +804,84 @@ async def get_la_averages(request: Request):
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}} return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
@app.get("/api/national-averages") _KS2_NATIONAL_METRICS = [
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_national_averages(request: Request):
"""
Compute national average for each metric from the latest data year.
Returns separate averages for primary (KS2) and secondary (KS4) schools.
Values are derived from the loaded DataFrame so they automatically
stay current when new data is loaded.
"""
df = load_school_data()
if df.empty:
return {"primary": {}, "secondary": {}}
ks2_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",
"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",
"overall_absence_pct", "persistent_absence_pct", "overall_absence_pct", "persistent_absence_pct",
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct", "disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct",
] ]
ks4_metrics = [ _KS4_NATIONAL_METRICS = [
"attainment_8_score", "progress_8_score", "attainment_8_score", "progress_8_score",
"english_maths_standard_pass_pct", "english_maths_strong_pass_pct", "english_maths_standard_pass_pct", "english_maths_strong_pass_pct",
"ebacc_entry_pct", "ebacc_standard_pass_pct", "ebacc_strong_pass_pct", "ebacc_entry_pct", "ebacc_standard_pass_pct", "ebacc_strong_pass_pct",
"ebacc_avg_score", "gcse_grade_91_pct", "ebacc_avg_score", "gcse_grade_91_pct",
] ]
def _means(sub_df, metric_list):
out = {} def _national_averages_payload(df: pd.DataFrame) -> dict:
for col in metric_list: """National-averages payload shared by /api/national-averages and
if col in sub_df.columns: /api/compare.
val = sub_df[col].dropna()
if len(val) > 0: Both series are persisted marts computed at import time: official DfE
out[col] = round(float(val.mean()), 2) KS2 figures (fact_ks2_national_averages) and official DfE KS4 figures
return out (fact_ks4_national_averages) — the API never aggregates the performance
dataframe per request. If the KS4 mart hasn't been built yet, the
secondary series is empty — never a computed stand-in, because the UI
labels these figures as official DfE data.
"""
if df.empty:
return {"primary": {}, "secondary": {}}
latest_year = int(df["year"].max()) latest_year = int(df["year"].max())
df_latest = df[df["year"] == latest_year]
# Primary: schools where KS2 data is non-null from . import database
primary_df = df_latest[df_latest["rwm_expected_pct"].notna()] from .models import Ks2NationalAverage, Ks4NationalAverage
# Secondary: schools where KS4 data is non-null
secondary_df = df_latest[df_latest["attainment_8_score"].notna()]
latest_primary = _means(primary_df, ks2_metrics) def _row_metrics(row, metric_list):
latest_secondary = _means(secondary_df, ks4_metrics) out = {}
for col in metric_list:
# Per-year KS2 primary averages: use official DfE figures from the mart table. val = getattr(row, col, None)
# Per-year KS4 secondary averages: computed from our dataset (no DfE dataset yet).
from .database import SessionLocal
from .models import Ks2NationalAverage
by_year = []
try:
db = SessionLocal()
nat_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
# Build a lookup of computed secondary averages per year as fallback
secondary_by_year = {}
for yr in sorted(df["year"].dropna().unique()):
yr = int(yr)
df_yr = df[df["year"] == yr]
secondary_by_year[yr] = _means(
df_yr[df_yr["attainment_8_score"].notna()], ks4_metrics
)
# Merge: official KS2 figures + computed KS4 figures per year
ks2_years = {r.year for r in nat_rows}
all_years = sorted(ks2_years | set(secondary_by_year.keys()))
nat_lookup = {r.year: r for r in nat_rows}
for yr in all_years:
primary_yr: dict = {}
if yr in nat_lookup:
r = nat_lookup[yr]
for col in ks2_metrics:
val = getattr(r, col, None)
if val is not None: if val is not None:
primary_yr[col] = val out[col] = val
by_year.append({ return out
"year": yr,
"primary": primary_yr, ks2_rows: list = []
"secondary": secondary_by_year.get(yr, {}), ks4_rows: list = []
}) db = None
try:
db = database.SessionLocal()
try:
ks2_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
except Exception:
db.rollback()
try:
ks4_rows = db.query(Ks4NationalAverage).order_by(Ks4NationalAverage.year).all()
except Exception:
db.rollback()
except Exception:
pass
finally: finally:
if db is not None:
db.close() db.close()
# Update latest_primary with official DfE figure for the latest year if available primary_by_year = {r.year: _row_metrics(r, _KS2_NATIONAL_METRICS) for r in ks2_rows}
if by_year: secondary_by_year = {r.year: _row_metrics(r, _KS4_NATIONAL_METRICS) for r in ks4_rows}
latest_official = next((e["primary"] for e in reversed(by_year) if e["primary"]), None)
if latest_official: all_years = sorted(set(primary_by_year) | set(secondary_by_year))
latest_primary = latest_official by_year = [
{
"year": yr,
"primary": primary_by_year.get(yr, {}),
"secondary": secondary_by_year.get(yr, {}),
}
for yr in all_years
]
latest_primary = next((e["primary"] for e in reversed(by_year) if e["primary"]), {})
latest_secondary = next((e["secondary"] for e in reversed(by_year) if e["secondary"]), {})
return { return {
"year": latest_year, "year": latest_year,
@@ -809,6 +891,17 @@ async def get_national_averages(request: Request):
} }
@app.get("/api/national-averages")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_national_averages(request: Request):
"""
National averages: official DfE KS2 figures per year plus computed
KS4 averages, derived from the loaded DataFrame and the
fact_ks2_national_averages mart.
"""
return _national_averages_payload(load_school_data())
@app.get("/api/metrics") @app.get("/api/metrics")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute") @limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_available_metrics(request: Request): async def get_available_metrics(request: Request):
+381 -109
View File
@@ -3,21 +3,58 @@ Data loading module — reads from marts.* tables built by dbt.
Provides efficient queries with caching. Provides efficient queries with caching.
""" """
import logging
import re
import pandas as pd import pandas as pd
import numpy as np import numpy as np
from typing import Optional, Dict, Tuple, List from typing import Optional, Dict, Tuple, List
import requests import requests
from sqlalchemy import text from sqlalchemy import text
import sqlalchemy.exc
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import settings from .config import settings
from .database import SessionLocal, engine from .database import SessionLocal, engine
from .models import ( from .models import (
DimSchool, DimLocation, KS2Performance, DimSchool, DimLocation, KS2Performance,
FactOfstedInspection, FactParentView, FactAdmissions, FactOfstedInspection, FactAdmissions,
FactDeprivation, FactFinance, FactPupilCharacteristics, FactDeprivation, FactFinance, FactPupilCharacteristics,
) )
from .ofsted_codes import ofsted_page_url, report_card_labels
from .schemas import SCHOOL_TYPE_MAP from .schemas import SCHOOL_TYPE_MAP
from .gias_codes import (
ADMISSIONS_POLICY,
ESTABLISHMENT_STATUS,
PHASE_OF_EDUCATION,
RELIGIOUS_CHARACTER,
SCHOOL_TYPE,
translate,
)
# mart code column -> (API name column, dictionary)
_GIAS_CODE_COLUMNS = {
"phase_code": ("phase", PHASE_OF_EDUCATION),
"school_type_code": ("school_type", SCHOOL_TYPE),
"status_code": ("status", ESTABLISHMENT_STATUS),
"religious_character_code": ("religious_denomination", RELIGIOUS_CHARACTER),
"admissions_policy_code": ("admissions_policy", ADMISSIONS_POLICY),
}
def translate_gias_code_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Map GIAS code columns to today's name columns (API contract).
Runs immediately after pd.read_sql so every downstream consumer —
filters, PHASE_GROUPS, payloads, /api/filters — keeps seeing names.
DataFrames without the code columns (old schema, test fixtures) pass
through unchanged.
"""
for code_col, (name_col, mapping) in _GIAS_CODE_COLUMNS.items():
if code_col in df.columns:
df[name_col] = df[code_col].map(lambda c: translate(c, mapping))
return df
_postcode_cache: Dict[str, Tuple[float, float]] = {} _postcode_cache: Dict[str, Tuple[float, float]] = {}
_typesense_client = None _typesense_client = None
@@ -118,14 +155,16 @@ _MAIN_QUERY = text("""
SELECT SELECT
s.urn, s.urn,
s.school_name, s.school_name,
s.phase, s.phase_code,
s.school_type, s.school_type_code,
s.academy_trust_name AS trust_name, s.academy_trust_name AS trust_name,
s.academy_trust_uid AS trust_uid, s.academy_trust_uid AS trust_uid,
s.religious_character AS religious_denomination, s.religious_character_code,
s.gender, s.gender,
s.age_range, s.age_range,
s.admissions_policy, s.has_sixth_form,
s.status_code,
s.admissions_policy_code,
s.capacity, s.capacity,
s.total_pupils AS gias_total_pupils, s.total_pupils AS gias_total_pupils,
s.headteacher_name, s.headteacher_name,
@@ -133,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,
@@ -152,13 +192,20 @@ _MAIN_QUERY = text("""
p.reading_high_pct, p.reading_high_pct,
p.reading_avg_score, p.reading_avg_score,
p.reading_progress, p.reading_progress,
p.reading_progress_lower_ci,
p.reading_progress_upper_ci,
p.writing_expected_pct, p.writing_expected_pct,
p.writing_high_pct, p.writing_high_pct,
p.writing_progress, p.writing_progress,
p.writing_progress_lower_ci,
p.writing_progress_upper_ci,
p.writing_working_towards_pct,
p.maths_expected_pct, p.maths_expected_pct,
p.maths_high_pct, p.maths_high_pct,
p.maths_avg_score, p.maths_avg_score,
p.maths_progress, p.maths_progress,
p.maths_progress_lower_ci,
p.maths_progress_upper_ci,
p.gps_expected_pct, p.gps_expected_pct,
p.gps_high_pct, p.gps_high_pct,
p.gps_avg_score, p.gps_avg_score,
@@ -187,6 +234,9 @@ _MAIN_QUERY = text("""
p.progress_8_maths, p.progress_8_maths,
p.progress_8_ebacc, p.progress_8_ebacc,
p.progress_8_open, p.progress_8_open,
p.progress_8_banding,
p.attainment_8_disadvantage_gap,
p.progress_8_disadvantage_gap,
p.english_maths_strong_pass_pct, p.english_maths_strong_pass_pct,
p.english_maths_standard_pass_pct, p.english_maths_standard_pass_pct,
p.ebacc_entry_pct, p.ebacc_entry_pct,
@@ -207,18 +257,104 @@ _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
ORDER BY s.school_name, p.year ORDER BY s.school_name, p.year
""") """)
# Fallback used when marts.dim_school predates the has_sixth_form column
# (i.e. the nightly dbt pipeline hasn't rebuilt the mart yet on this DB).
# Keeps the column present as NULL so downstream code — including the
# app.py fallback branch — behaves as designed instead of KeyError-ing.
_MAIN_QUERY_NO_SIXTH_FORM = text(
str(_MAIN_QUERY).replace("s.has_sixth_form,", "NULL AS has_sixth_form,")
)
assert "NULL AS has_sixth_form" in str(_MAIN_QUERY_NO_SIXTH_FORM), (
"expected replacement of 's.has_sixth_form,' to have taken effect"
)
# Fallback used when marts.dim_school predates the GIAS code-dictionary
# migration (i.e. the nightly dbt pipeline hasn't rebuilt the mart yet on
# this DB, so it still has the old name columns instead of *_code columns).
_MAIN_QUERY_LEGACY_NAMES = str(_MAIN_QUERY)
_LEGACY_NAME_REPLACEMENTS = [
("s.phase_code,", "s.phase,"),
("s.school_type_code,", "s.school_type,"),
(
"s.religious_character_code,",
"s.religious_character AS religious_denomination,",
),
("s.status_code,", "s.status,"),
("s.admissions_policy_code,", "s.admissions_policy,"),
]
for _old, _new in _LEGACY_NAME_REPLACEMENTS:
assert _old in _MAIN_QUERY_LEGACY_NAMES, (
f"expected {_old!r} to be present in _MAIN_QUERY before replacement"
)
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
_GIAS_CODE_COLUMN_NAMES = (
"phase_code",
"school_type_code",
"religious_character_code",
"status_code",
"admissions_policy_code",
)
_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist')
def _missing_column_name(exc: Exception) -> Optional[str]:
"""Name of the missing column from a psycopg2 UndefinedColumn error.
Inspects exc.orig (the DBAPI error), whose message names only the
offending column — str(exc) also embeds the full SQL statement, which
contains every column name and therefore must not be matched against.
"""
orig = getattr(exc, "orig", None)
match = _MISSING_COLUMN_RE.search(str(orig) if orig is not None else str(exc))
return match.group(1) if match else None
def load_school_data_as_dataframe() -> pd.DataFrame: def load_school_data_as_dataframe() -> pd.DataFrame:
"""Load all school + KS2 data as a pandas DataFrame.""" """Load all school + KS2 data as a pandas DataFrame."""
try: try:
df = pd.read_sql(_MAIN_QUERY, engine) df = pd.read_sql(_MAIN_QUERY, engine)
except sqlalchemy.exc.ProgrammingError as exc:
missing = _missing_column_name(exc)
if missing in _GIAS_CODE_COLUMN_NAMES:
logging.getLogger(__name__).warning(
"marts predate the GIAS code migration — falling back to "
"legacy name-column query: %s",
exc,
)
try:
df = pd.read_sql(_MAIN_QUERY_LEGACY_NAMES, engine)
except Exception as exc2:
print(f"Warning: Could not load school data from marts: {exc2}")
return pd.DataFrame()
elif missing == "has_sixth_form":
logging.getLogger(__name__).warning(
"marts.dim_school is missing has_sixth_form (pipeline hasn't "
"rebuilt the mart yet on this DB) — retrying without it: %s",
exc,
)
try:
df = pd.read_sql(_MAIN_QUERY_NO_SIXTH_FORM, engine)
except Exception as exc2:
print(f"Warning: Could not load school data from marts: {exc2}")
return pd.DataFrame()
else:
print(f"Warning: Could not load school data from marts: {exc}")
return pd.DataFrame()
except Exception as exc: except Exception as exc:
print(f"Warning: Could not load school data from marts: {exc}") print(f"Warning: Could not load school data from marts: {exc}")
return pd.DataFrame() return pd.DataFrame()
@@ -226,6 +362,8 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
if df.empty: if df.empty:
return df return df
df = translate_gias_code_columns(df)
# Build address string # Build address string
df["address"] = df.apply( df["address"] = df.apply(
lambda r: ", ".join( lambda r: ", ".join(
@@ -393,37 +531,109 @@ def get_data_info(db: Session = None) -> dict:
# SUPPLEMENTARY DATA — per-school detail page # SUPPLEMENTARY DATA — per-school detail page
# ============================================================================= # =============================================================================
def get_supplementary_data(db: Session, urn: int) -> dict: def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None) -> dict:
"""Fetch all supplementary data for a single school URN.""" """State-school benchmarks computed from our dataset (spec §5/§8.6).
result = {}
def safe_query(model, pk_field, latest_field=None): NOT official DfE figures — consumers must label them
try: "state-school average (computed from our dataset)". The disadvantaged
q = db.query(model).filter(getattr(model, pk_field) == urn) attainment average is weighted by cohort size (eligible_pupils) so
if latest_field: small schools don't dominate.
q = q.order_by(getattr(model, latest_field).desc())
return q.first() Context measures (FSM/EAL/pupil counts) come from `census_benchmarks`
except Exception as e: (the fact_census_benchmarks mart, pupil-weighted, keyed by phase): the
import logging performance df has no fsm_pct at all, and its eal/disadvantaged columns
logging.getLogger(__name__).error("safe_query failed for %s: %s", model.__name__, e) are KS2-only — medianing them for "secondary" produced junk anchors
db.rollback() from the handful of all-through schools. When the mart is unavailable
these are None; never fall back across measure definitions.
"""
if df.empty or "year" not in df.columns:
return {}
latest_year = df["year"].max()
if pd.isna(latest_year):
return {}
d = df[df["year"] == latest_year]
if d.empty:
return {}
is_secondary = (
d["attainment_8_score"].notna()
if "attainment_8_score" in d.columns
else pd.Series(False, index=d.index)
)
prim, sec = d[~is_secondary], d[is_secondary]
def _median(sub, col):
if col not in sub.columns:
return None return None
v = sub[col].median()
return round(float(v), 1) if pd.notna(v) else None
# Latest Ofsted inspection def _weighted_disadvantaged(sub):
o = safe_query(FactOfstedInspection, "urn", "inspection_date") needed = {"rwm_expected_disadvantaged_pct", "eligible_pupils"}
result["ofsted"] = ( if not needed <= set(sub.columns):
{ return None
s = sub.dropna(subset=list(needed))
if s.empty or s["eligible_pupils"].sum() == 0:
return None
w = (
(s["rwm_expected_disadvantaged_pct"] * s["eligible_pupils"]).sum()
/ s["eligible_pupils"].sum()
)
return round(float(w), 1)
def _block(sub, phase, with_disadvantaged):
census = (census_benchmarks or {}).get(phase) or {}
block = {
"eal_pct": census.get("eal_pct"),
"sen_support_pct": _median(sub, "sen_support_pct"),
"disadvantaged_pct": _median(sub, "disadvantaged_pct") if with_disadvantaged else None,
"fsm_pct": census.get("fsm_pct"),
"median_pupils": census.get("median_pupils"),
}
if with_disadvantaged:
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
return block
return {
"source": "state-school average (computed from our dataset)",
"year": int(latest_year),
"primary": _block(prim, "primary", with_disadvantaged=True),
"secondary": _block(sec, "secondary", with_disadvantaged=False),
}
def _ofsted_block(o, urn: int) -> dict:
"""Serialize the latest Ofsted inspection row for API responses.
`grade_source` records where the effective overall grade came from:
a graded (Section 5) inspection, or carried forward from an ungraded
(Section 8) outcome — materially different claims a UI must be able
to distinguish. `report_card` holds coded+labelled renewed-framework
(Nov 2025) area judgements; safeguarding is a separate boolean and
never appears among the graded areas.
"""
if o.overall_effectiveness is not None:
grade_source = "graded"
overall = o.overall_effectiveness
elif o.ungraded_grade is not None:
# Fall back to the grade parsed from an ungraded (Section 8) outcome
# (e.g. "School remains Good") so the detail page matches the list badge.
grade_source = "ungraded_carried_forward"
overall = o.ungraded_grade
else:
grade_source = None
overall = None
block = {
"framework": o.framework, "framework": o.framework,
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None, "inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
"inspection_type": o.inspection_type, "rc_inspection_date": (
# Fall back to the grade parsed from an ungraded (Section 8) outcome o.rc_inspection_date.isoformat()
# (e.g. "School remains Good") when there's no graded grade, so the if getattr(o, "rc_inspection_date", None)
# detail page matches the list badge. else None
"overall_effectiveness": (
o.overall_effectiveness
if o.overall_effectiveness is not None
else o.ungraded_grade
), ),
"inspection_type": o.inspection_type,
"overall_effectiveness": overall,
"grade_source": grade_source,
"quality_of_education": o.quality_of_education, "quality_of_education": o.quality_of_education,
"behaviour_attitudes": o.behaviour_attitudes, "behaviour_attitudes": o.behaviour_attitudes,
"personal_development": o.personal_development, "personal_development": o.personal_development,
@@ -441,52 +651,14 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
"rc_early_years": o.rc_early_years, "rc_early_years": o.rc_early_years,
"rc_sixth_form": o.rc_sixth_form, "rc_sixth_form": o.rc_sixth_form,
"report_url": o.report_url, "report_url": o.report_url,
"ofsted_page_url": ofsted_page_url(urn),
} }
if o block["report_card"] = report_card_labels(block)
else None return block
)
# Parent View
pv = safe_query(FactParentView, "urn")
result["parent_view"] = (
{
"survey_date": pv.survey_date.isoformat() if pv.survey_date else None,
"total_responses": pv.total_responses,
"q_happy_pct": pv.q_happy_pct,
"q_safe_pct": pv.q_safe_pct,
"q_behaviour_pct": pv.q_behaviour_pct,
"q_bullying_pct": pv.q_bullying_pct,
"q_communication_pct": pv.q_communication_pct,
"q_progress_pct": pv.q_progress_pct,
"q_teaching_pct": pv.q_teaching_pct,
"q_information_pct": pv.q_information_pct,
"q_curriculum_pct": pv.q_curriculum_pct,
"q_future_pct": pv.q_future_pct,
"q_leadership_pct": pv.q_leadership_pct,
"q_wellbeing_pct": pv.q_wellbeing_pct,
"q_recommend_pct": pv.q_recommend_pct,
}
if pv
else None
)
# Census (latest year of fact_pupil_characteristics) def _admissions_row_dict(a) -> dict:
pc = safe_query(FactPupilCharacteristics, "urn", "year") """Serialize one fact_admissions row for API responses."""
result["census"] = (
{
"year": pc.year,
"total_pupils": pc.total_pupils,
"female_pupils": pc.female_pupils,
"male_pupils": pc.male_pupils,
"fsm_pct": pc.fsm_pct,
"eal_pct": pc.eal_pct,
}
if pc
else None
)
# Admissions — all years, oldest first (for the multi-year trend view).
def _admissions_row(a):
return { return {
"year": a.year, "year": a.year,
"school_phase": a.school_phase, "school_phase": a.school_phase,
@@ -497,49 +669,35 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
"first_preference_offer_pct": a.first_preference_offer_pct, "first_preference_offer_pct": a.first_preference_offer_pct,
"oversubscription_ratio": a.oversubscription_ratio, "oversubscription_ratio": a.oversubscription_ratio,
"oversubscribed": a.oversubscribed, "oversubscribed": a.oversubscribed,
"total_offers": a.total_offers,
"second_preference_offers": a.second_preference_offers,
"third_preference_offers": a.third_preference_offers,
"cross_la_applications": a.cross_la_applications,
"cross_la_offers": a.cross_la_offers,
} }
try:
admissions_rows = (
db.query(FactAdmissions)
.filter(FactAdmissions.urn == urn)
.order_by(FactAdmissions.year.asc())
.all()
)
except Exception as e:
import logging
logging.getLogger(__name__).error("admissions history query failed: %s", e)
db.rollback()
admissions_rows = []
history = [_admissions_row(a) for a in admissions_rows] def _census_dict(pc) -> dict:
result["admissions_history"] = history return {
# Keep the single latest-year object for backwards-compatible consumers "year": pc.year,
# (hero chips, etc.). "total_pupils": pc.total_pupils,
result["admissions"] = history[-1] if history else None "female_pupils": pc.female_pupils,
"male_pupils": pc.male_pupils,
"fsm_pct": pc.fsm_pct,
"eal_pct": pc.eal_pct,
}
# SEN detail — not available in current marts
result["sen_detail"] = None
# Phonics — no school-level data on EES def _deprivation_dict(d) -> dict:
result["phonics"] = None return {
# Deprivation
d = safe_query(FactDeprivation, "urn")
result["deprivation"] = (
{
"lsoa_code": d.lsoa_code, "lsoa_code": d.lsoa_code,
"idaci_score": d.idaci_score, "idaci_score": d.idaci_score,
"idaci_decile": d.idaci_decile, "idaci_decile": d.idaci_decile,
} }
if d
else None
)
# Finance (latest year)
f = safe_query(FactFinance, "urn", "year") def _finance_dict(f) -> dict:
result["finance"] = ( return {
{
"year": f.year, "year": f.year,
"per_pupil_spend": f.per_pupil_spend, "per_pupil_spend": f.per_pupil_spend,
"staff_cost_pct": f.staff_cost_pct, "staff_cost_pct": f.staff_cost_pct,
@@ -547,8 +705,122 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
"support_staff_cost_pct": f.support_staff_cost_pct, "support_staff_cost_pct": f.support_staff_cost_pct,
"premises_cost_pct": f.premises_cost_pct, "premises_cost_pct": f.premises_cost_pct,
} }
if f
else None
def _empty_supplementary() -> dict:
return {
"ofsted": None,
"census": None,
"admissions": None,
"admissions_history": [],
"sen_detail": None,
"phonics": None,
"deprivation": None,
"finance": None,
}
def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
"""Fetch supplementary data for many URNs with one query per table
(WHERE urn IN (...)) instead of ~5 queries per school, collapsing the
per-request round-trips from 5*N to a constant 5. Returns {urn: block}
with the same shape get_supplementary_data produces per URN.
Each table is queried independently and failures degrade that table to
empty for every URN — a missing mart never blanks the others.
"""
urns = [int(u) for u in urns]
result = {urn: _empty_supplementary() for urn in urns}
if not urns:
return result
def _safe(fn):
try:
fn()
except Exception as e:
import logging
logging.getLogger(__name__).error("batch supplementary query failed: %s", e)
db.rollback()
# Ofsted — latest inspection per URN. Ordered so the first row seen per
# URN is the most recent.
def _ofsted():
rows = (
db.query(FactOfstedInspection)
.filter(FactOfstedInspection.urn.in_(urns))
.order_by(FactOfstedInspection.urn, FactOfstedInspection.inspection_date.desc())
.all()
) )
seen = set()
for o in rows:
if o.urn in seen:
continue
seen.add(o.urn)
result[o.urn]["ofsted"] = _ofsted_block(o, o.urn)
_safe(_ofsted)
# Census — latest year per URN.
def _census():
rows = (
db.query(FactPupilCharacteristics)
.filter(FactPupilCharacteristics.urn.in_(urns))
.order_by(FactPupilCharacteristics.urn, FactPupilCharacteristics.year.desc())
.all()
)
seen = set()
for pc in rows:
if pc.urn in seen:
continue
seen.add(pc.urn)
result[pc.urn]["census"] = _census_dict(pc)
_safe(_census)
# Admissions — all years per URN, oldest first (multi-year trend view).
def _admissions():
rows = (
db.query(FactAdmissions)
.filter(FactAdmissions.urn.in_(urns))
.order_by(FactAdmissions.urn, FactAdmissions.year.asc())
.all()
)
history: dict = {urn: [] for urn in urns}
for a in rows:
history[a.urn].append(_admissions_row_dict(a))
for urn, rows_for_urn in history.items():
result[urn]["admissions_history"] = rows_for_urn
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
_safe(_admissions)
# Deprivation — one row per URN.
def _deprivation():
rows = (
db.query(FactDeprivation)
.filter(FactDeprivation.urn.in_(urns))
.all()
)
for d in rows:
result[d.urn]["deprivation"] = _deprivation_dict(d)
_safe(_deprivation)
# Finance — latest year per URN.
def _finance():
rows = (
db.query(FactFinance)
.filter(FactFinance.urn.in_(urns))
.order_by(FactFinance.urn, FactFinance.year.desc())
.all()
)
seen = set()
for f in rows:
if f.urn in seen:
continue
seen.add(f.urn)
result[f.urn]["finance"] = _finance_dict(f)
_safe(_finance)
return result return result
def get_supplementary_data(db: Session, urn: int) -> dict:
"""Supplementary data for a single URN (thin wrapper over the batch)."""
return get_supplementary_data_batch(db, [urn])[int(urn)]
+155
View File
@@ -0,0 +1,155 @@
"""GIAS code -> name dictionaries.
GENERATED by pipeline/scripts/generate_gias_codes.py from the GIAS bulk CSV
— do not edit by hand; rerun the script when the dbt drift test warns.
The canonical file is backend/gias_codes.py; pipeline/scripts/gias_codes.py
must be byte-identical (enforced by backend/tests/test_gias_codes.py).
"""
from __future__ import annotations
import logging
import math
logger = logging.getLogger(__name__)
SCHOOL_TYPE: dict[int, str] = {
1: "Community school",
2: "Voluntary aided school",
3: "Voluntary controlled school",
5: "Foundation school",
6: "City technology college",
7: "Community special school",
8: "Non-maintained special school",
10: "Other independent special school",
11: "Other independent school",
12: "Foundation special school",
14: "Pupil referral unit",
15: "Local authority nursery school",
18: "Further education",
24: "Secure units",
25: "Offshore schools",
26: "Service children's education",
27: "Miscellaneous",
28: "Academy sponsor led",
29: "Higher education institutions",
30: "Welsh establishment",
31: "Sixth form centres",
32: "Special post 16 institution",
33: "Academy special sponsor led",
34: "Academy converter",
35: "Free schools",
36: "Free schools special",
37: "British schools overseas",
38: "Free schools alternative provision",
39: "Free schools 16 to 19",
40: "University technical college",
41: "Studio schools",
42: "Academy alternative provision converter",
43: "Academy alternative provision sponsor led",
44: "Academy special converter",
45: "Academy 16-19 converter",
46: "Academy 16 to 19 sponsor led",
49: "Online provider",
56: "Institution funded by other government department",
57: "Academy secure 16 to 19",
}
ESTABLISHMENT_STATUS: dict[int, str] = {
1: "Open",
2: "Closed",
3: "Open, but proposed to close",
4: "Proposed to open",
}
PHASE_OF_EDUCATION: dict[int, str] = {
0: "Not applicable",
1: "Nursery",
2: "Primary",
3: "Middle deemed primary",
4: "Secondary",
5: "Middle deemed secondary",
6: "16 plus",
7: "All-through",
}
OFFICIAL_SIXTH_FORM: dict[int, str] = {
0: "Not applicable",
1: "Has a sixth form",
2: "Does not have a sixth form",
9: "",
}
RELIGIOUS_CHARACTER: dict[int, str] = {
0: "Does not apply",
2: "Church of England",
3: "Roman Catholic",
4: "Methodist",
5: "Jewish",
6: "None",
7: "Muslim",
8: "Seventh Day Adventist",
9: "Church of England/Methodist",
10: "Methodist/Church of England",
11: "Church of England/Roman Catholic",
12: "Church of England/United Reformed Church",
13: "Roman Catholic/Church of England",
14: "Quaker",
15: "Christian",
16: "United Reformed Church",
17: "Congregational Church",
18: "Free Church",
19: "Church of England/Free Church",
20: "Church of England/Christian",
21: "Sikh",
22: "Greek Orthodox",
24: "Buddhist",
25: "Hindu",
26: "Moravian",
28: "Inter- / non- denominational",
29: "Multi-faith",
30: "Church of England/Methodist/United Reform Church/Baptist",
31: "Anglican",
32: "Anglican/Christian",
33: "Anglican/Evangelical",
34: "Anglican/Church of England",
35: "Catholic",
36: "Charadi Jewish",
37: "Christian/Evangelical",
38: "Christian Science",
39: "Christian/Methodist",
40: "Christian/non-denominational",
41: "Church of England/Evangelical",
42: "Islam",
43: "Orthodox Jewish",
44: "Plymouth Brethren Christian Church",
45: "Protestant",
46: "Protestant/Evangelical",
47: "Reformed Baptist",
48: "Roman Catholic/Anglican",
49: "Sunni Deobandi",
99: "",
}
ADMISSIONS_POLICY: dict[int, str] = {
0: "Not applicable",
2: "Selective",
4: "Non-selective",
9: "",
}
def translate(code, mapping: dict[int, str]) -> str | None:
"""Translate a GIAS code to its display name.
None/NaN -> None (column absent or suppressed). Unknown codes degrade to
"Unknown (<code>)" with a warning so a new DfE value never blanks the UI.
"""
if code is None or (isinstance(code, float) and math.isnan(code)):
return None
code = int(code)
if code not in mapping:
logger.warning("Unknown GIAS code %s (not in dictionary)", code)
return f"Unknown ({code})"
return mapping[code]
+22
View File
@@ -433,6 +433,25 @@ def _apply_schema_alterations():
conn.commit() conn.commit()
def _apply_schema_drops():
"""
Drop tables retired from the schema. Idempotent (DROP … IF EXISTS), so it's
safe to run on every migration. Add entries here when a model is removed.
"""
drops = [
# v6: Ofsted Parent View feature removed
"DROP TABLE IF EXISTS marts.fact_parent_view CASCADE",
]
from sqlalchemy import text as sa_text
with engine.connect() as conn:
for stmt in drops:
try:
conn.execute(sa_text(stmt))
except Exception as e:
print(f" Warning: drop skipped ({e})")
conn.commit()
def run_full_migration(geocode: bool = False) -> bool: def run_full_migration(geocode: bool = False) -> bool:
""" """
Run a complete migration: drop all tables and reimport from CSV. Run a complete migration: drop all tables and reimport from CSV.
@@ -479,6 +498,9 @@ def run_full_migration(geocode: bool = False) -> bool:
print("Applying column additions to supplementary tables...") print("Applying column additions to supplementary tables...")
_apply_schema_alterations() _apply_schema_alterations()
print("Dropping retired tables...")
_apply_schema_drops()
print("\nLoading CSV data...") print("\nLoading CSV data...")
df = load_csv_data(settings.data_dir) df = load_csv_data(settings.data_dir)
+59 -28
View File
@@ -17,21 +17,22 @@ class DimSchool(Base):
urn = Column(Integer, primary_key=True) urn = Column(Integer, primary_key=True)
school_name = Column(String(255), nullable=False) school_name = Column(String(255), nullable=False)
phase = Column(String(100)) phase_code = Column(Integer)
school_type = Column(String(100)) school_type_code = Column(Integer)
academy_trust_name = Column(String(255)) academy_trust_name = Column(String(255))
academy_trust_uid = Column(String(20)) academy_trust_uid = Column(String(20))
religious_character = Column(String(100)) religious_character_code = Column(Integer)
gender = Column(String(20)) gender = Column(String(20))
age_range = Column(String(20)) age_range = Column(String(20))
has_sixth_form = Column(Boolean)
capacity = Column(Integer) capacity = Column(Integer)
total_pupils = Column(Integer) total_pupils = Column(Integer)
headteacher_name = Column(String(200)) headteacher_name = Column(String(200))
website = Column(String(255)) website = Column(String(255))
telephone = Column(String(30)) telephone = Column(String(30))
status = Column(String(50)) status_code = Column(Integer)
nursery_provision = Column(Boolean) nursery_provision = Column(Boolean)
admissions_policy = Column(String(50)) admissions_policy_code = Column(Integer)
# Denormalised Ofsted summary (updated by monthly pipeline) # Denormalised Ofsted summary (updated by monthly pipeline)
ofsted_grade = Column(Integer) ofsted_grade = Column(Integer)
ofsted_date = Column(Date) ofsted_date = Column(Date)
@@ -87,6 +88,15 @@ class KS2Performance(Base):
maths_high_pct = Column(Float) maths_high_pct = Column(Float)
maths_avg_score = Column(Float) maths_avg_score = Column(Float)
maths_progress = Column(Float) maths_progress = Column(Float)
# Progress confidence intervals + writing working-towards (published
# for years with progress measures, i.e. up to 2022/23)
reading_progress_lower_ci = Column(Float)
reading_progress_upper_ci = Column(Float)
writing_progress_lower_ci = Column(Float)
writing_progress_upper_ci = Column(Float)
writing_working_towards_pct = Column(Float)
maths_progress_lower_ci = Column(Float)
maths_progress_upper_ci = Column(Float)
gps_expected_pct = Column(Float) gps_expected_pct = Column(Float)
gps_high_pct = Column(Float) gps_high_pct = Column(Float)
gps_avg_score = Column(Float) gps_avg_score = Column(Float)
@@ -146,32 +156,12 @@ class FactOfstedInspection(Base):
rc_leadership_governance = Column(Integer) rc_leadership_governance = Column(Integer)
rc_early_years = Column(Integer) rc_early_years = Column(Integer)
rc_sixth_form = Column(Integer) rc_sixth_form = Column(Integer)
# Start date of the report-card inspection itself (renewed framework,
# Nov 2025+). Null for rows without report-card grades.
rc_inspection_date = Column(Date)
report_url = Column(Text) report_url = Column(Text)
class FactParentView(Base):
"""Ofsted Parent View survey — latest per school."""
__tablename__ = "fact_parent_view"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
survey_date = Column(Date)
total_responses = Column(Integer)
q_happy_pct = Column(Float)
q_safe_pct = Column(Float)
q_behaviour_pct = Column(Float)
q_bullying_pct = Column(Float)
q_communication_pct = Column(Float)
q_progress_pct = Column(Float)
q_teaching_pct = Column(Float)
q_information_pct = Column(Float)
q_curriculum_pct = Column(Float)
q_future_pct = Column(Float)
q_leadership_pct = Column(Float)
q_wellbeing_pct = Column(Float)
q_recommend_pct = Column(Float)
class FactAdmissions(Base): class FactAdmissions(Base):
"""School admissions — one row per URN per year.""" """School admissions — one row per URN per year."""
__tablename__ = "fact_admissions" __tablename__ = "fact_admissions"
@@ -187,6 +177,11 @@ class FactAdmissions(Base):
total_applications = Column(Integer) total_applications = Column(Integer)
first_preference_applications = Column(Integer) first_preference_applications = Column(Integer)
first_preference_offers = Column(Integer) first_preference_offers = Column(Integer)
total_offers = Column(Integer)
second_preference_offers = Column(Integer)
third_preference_offers = Column(Integer)
cross_la_applications = Column(Integer)
cross_la_offers = Column(Integer)
first_preference_offer_pct = Column(Float) first_preference_offer_pct = Column(Float)
oversubscription_ratio = Column(Float) oversubscription_ratio = Column(Float)
oversubscribed = Column(Boolean) oversubscribed = Column(Boolean)
@@ -239,6 +234,42 @@ class FactFinance(Base):
premises_cost_pct = Column(Float) premises_cost_pct = Column(Float)
class CensusBenchmark(Base):
"""State-school context benchmarks from the pupil census — one row per phase.
fsm_pct / eal_pct are pupil-weighted means. Computed at import time;
consumers label them "state-school average (computed from our dataset)".
"""
__tablename__ = "fact_census_benchmarks"
__table_args__ = MARTS
phase = Column(String(20), primary_key=True)
year = Column(Integer)
fsm_pct = Column(Float)
eal_pct = Column(Float)
median_pupils = Column(Integer)
class Ks4NationalAverage(Base):
"""Official DfE KS4 national headline averages — one row per academic year.
gcse_grade_91_pct has no official national series and is always NULL.
"""
__tablename__ = "fact_ks4_national_averages"
__table_args__ = MARTS
year = Column(Integer, primary_key=True)
attainment_8_score = Column(Float)
progress_8_score = Column(Float)
english_maths_standard_pass_pct = Column(Float)
english_maths_strong_pass_pct = Column(Float)
ebacc_entry_pct = Column(Float)
ebacc_standard_pass_pct = Column(Float)
ebacc_strong_pass_pct = Column(Float)
ebacc_avg_score = Column(Float)
gcse_grade_91_pct = Column(Float)
class Ks2NationalAverage(Base): class Ks2NationalAverage(Base):
"""Official DfE KS2 national headline averages — one row per academic year.""" """Official DfE KS2 national headline averages — one row per academic year."""
__tablename__ = "fact_ks2_national_averages" __tablename__ = "fact_ks2_national_averages"
+44
View File
@@ -0,0 +1,44 @@
"""Ofsted renewed-framework (Nov 2025) report-card code translation.
Scale labels are the live-sampled vocabulary from the Ofsted MI file
(see pipeline/scripts/diagnose_compare_gaps.py, TASK 7 VALUE SAMPLE) —
verified against real data, not the consultation draft.
"""
REPORT_CARD_GRADE_NAMES = {
1: "Exceptional",
2: "Strong standard",
3: "Expected standard",
4: "Needs attention",
5: "Urgent improvement",
}
# Graded evaluation areas only — safeguarding is a separate boolean
# judgement and must never appear in grade counts or label maps.
_RC_AREA_KEYS = (
"rc_inclusion",
"rc_curriculum_teaching",
"rc_achievement",
"rc_attendance_behaviour",
"rc_personal_development",
"rc_leadership_governance",
"rc_early_years",
"rc_sixth_form",
)
def report_card_labels(ofsted: dict) -> dict:
"""{area_key: {code, label}} for populated, known-valued rc_* areas."""
out = {}
for key in _RC_AREA_KEYS:
code = ofsted.get(key)
label = REPORT_CARD_GRADE_NAMES.get(code)
if code is not None and label is not None:
out[key] = {"code": code, "label": label}
return out
def ofsted_page_url(urn: int) -> str:
"""The school's page on ofsted.gov.uk (all its reports live there —
we never deep-link an individual report)."""
return f"https://reports.ofsted.gov.uk/provider/21/{urn}"
+3
View File
@@ -543,11 +543,14 @@ SCHOOL_COLUMNS = [
"postcode", "postcode",
"religious_denomination", "religious_denomination",
"age_range", "age_range",
"has_sixth_form",
"status",
"gender", "gender",
"admissions_policy", "admissions_policy",
"ofsted_grade", "ofsted_grade",
"ofsted_date", "ofsted_date",
"ofsted_framework", "ofsted_framework",
"ofsted_rc_date",
"latitude", "latitude",
"longitude", "longitude",
] ]
View File
+102
View File
@@ -0,0 +1,102 @@
"""compute_benchmarks: state-school benchmarks computed from our dataset
(spec §5/§8.6). The disadvantaged average must be weighted by cohort size,
medians must ignore NaN, and only the latest year counts."""
import numpy as np
import pandas as pd
from backend.data_loader import compute_benchmarks
LATEST = 202425
def _df():
rows = [
# Six primary schools, latest year. Disadvantaged RWM chosen so the
# weighted average differs clearly from the unweighted mean:
# weighted = (40*100 + 60*300) / 400 = 55.0 ; unweighted mean = 50.0
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=100,
rwm_expected_disadvantaged_pct=40.0, eal_pct=10.0,
sen_support_pct=10.0, disadvantaged_pct=20.0, fsm_pct=15.0, total_pupils=200),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=300,
rwm_expected_disadvantaged_pct=60.0, eal_pct=20.0,
sen_support_pct=14.0, disadvantaged_pct=24.0, fsm_pct=17.0, total_pupils=280),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=np.nan,
rwm_expected_disadvantaged_pct=99.0, eal_pct=30.0,
sen_support_pct=18.0, disadvantaged_pct=30.0, fsm_pct=19.0, total_pupils=300),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=50,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=np.nan,
sen_support_pct=np.nan, disadvantaged_pct=np.nan, fsm_pct=np.nan, total_pupils=np.nan),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=40,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=40.0,
sen_support_pct=20.0, disadvantaged_pct=40.0, fsm_pct=21.0, total_pupils=350),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=60,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=50.0,
sen_support_pct=22.0, disadvantaged_pct=44.0, fsm_pct=23.0, total_pupils=400),
# Two secondary schools (attainment_8 non-null)
dict(year=LATEST, attainment_8_score=45.0, eligible_pupils=180,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=15.0,
sen_support_pct=12.0, disadvantaged_pct=22.0, fsm_pct=12.0, total_pupils=1000),
dict(year=LATEST, attainment_8_score=50.0, eligible_pupils=200,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=25.0,
sen_support_pct=16.0, disadvantaged_pct=26.0, fsm_pct=14.0, total_pupils=1200),
# An older-year primary row that must NOT influence anything
dict(year=202324, attainment_8_score=np.nan, eligible_pupils=500,
rwm_expected_disadvantaged_pct=1.0, eal_pct=99.0,
sen_support_pct=99.0, disadvantaged_pct=99.0, fsm_pct=99.0, total_pupils=9999),
]
return pd.DataFrame(rows)
def test_weighted_disadvantaged_average():
b = compute_benchmarks(_df())
# Row 3 has NaN eligible_pupils and must be excluded from the weighting.
assert b["primary"]["disadvantaged_rwm_expected_pct"] == 55.0
def test_medians_ignore_nan_and_older_years():
b = compute_benchmarks(_df())
assert b["year"] == LATEST
# sen medians over [10,14,18,20,22] = 18 — the only context measure still
# sourced from the performance df (the rest come from the census mart).
assert b["primary"]["sen_support_pct"] == 18.0
# disadvantaged_pct medians over [20,24,30,40,44] = 30
assert b["primary"]["disadvantaged_pct"] == 30.0
def test_benchmarks_use_census_mart_for_context():
census = {
"primary": {"year": LATEST, "fsm_pct": 25.3, "eal_pct": 21.8, "median_pupils": 240},
"secondary": {"year": LATEST, "fsm_pct": 24.1, "eal_pct": 18.9, "median_pupils": 980},
}
b = compute_benchmarks(_df(), census_benchmarks=census)
assert b["primary"]["fsm_pct"] == 25.3
assert b["primary"]["eal_pct"] == 21.8
assert b["secondary"]["eal_pct"] == 18.9
assert b["secondary"]["median_pupils"] == 980
def test_benchmarks_context_none_when_mart_missing():
# The performance df has no fsm_pct and its eal/disadvantaged columns are
# KS2-only — never silently fall back to medianing them for context.
b = compute_benchmarks(_df(), census_benchmarks=None)
assert b["primary"]["fsm_pct"] is None
assert b["primary"]["eal_pct"] is None
assert b["primary"]["median_pupils"] is None
def test_secondary_block_has_no_disadvantaged_rwm():
b = compute_benchmarks(_df())
assert "disadvantaged_rwm_expected_pct" not in b["secondary"]
# KS2-only columns must not produce a fake secondary disadvantaged anchor
# (the old median over all-through schools' KS2 rows produced 50%).
assert b["secondary"]["disadvantaged_pct"] is None
def test_provenance_string():
b = compute_benchmarks(_df())
assert b["source"] == "state-school average (computed from our dataset)"
def test_empty_df():
assert compute_benchmarks(pd.DataFrame()) == {}
+123
View File
@@ -0,0 +1,123 @@
"""/api/compare enrichment for the compare redesign: per-school
supplementary blocks, top-level national_averages (shared with the
/api/national-averages endpoint) and computed benchmarks — all additive."""
import types
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
LATEST = 202425
CANNED_SUPPLEMENTARY = {
"ofsted": {"overall_effectiveness": 2, "grade_source": "graded",
"report_card": {}, "ofsted_page_url": "https://reports.ofsted.gov.uk/provider/21/100140"},
"census": {"year": 202526, "fsm_pct": 29.8},
"admissions": {"year": 202627, "second_preference_offers": 4},
"admissions_history": [{"year": 202627, "second_preference_offers": 4}],
"sen_detail": None,
"phonics": None,
"deprivation": {"idaci_decile": 4},
"finance": None,
}
def _two_primary_schools_df() -> pd.DataFrame:
rows = []
for urn, name, rwm, dis in ((100140, "Plumcroft Primary School", 79.0, 72.0),
(138690, "Barclay Primary School", 87.0, 86.0)):
rows.append(dict(
urn=urn, school_name=name, local_authority="Greenwich",
school_type="Community school", address="1 Road", phase="Primary",
year=LATEST, rwm_expected_pct=rwm, attainment_8_score=np.nan,
eligible_pupils=60, rwm_expected_disadvantaged_pct=dis,
eal_pct=20.0, sen_support_pct=14.0, disadvantaged_pct=25.0,
total_pupils=1000.0,
))
return pd.DataFrame(rows)
class _StubNatRow:
year = 202425
rwm_expected_pct = 62.1
gps_expected_pct = 72.0
science_expected_pct = 81.0
class _StubSession:
def query(self, *a, **k):
return self
def order_by(self, *a, **k):
return self
def all(self):
return [_StubNatRow()]
def close(self):
pass
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
from backend import database as database_module
monkeypatch.setattr(app_module, "load_school_data", _two_primary_schools_df)
monkeypatch.setattr(
app_module,
"get_supplementary_data_batch",
lambda db, urns: {int(u): dict(CANNED_SUPPLEMENTARY) for u in urns},
)
monkeypatch.setattr(database_module, "SessionLocal", _StubSession)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_existing_shape_is_preserved(client):
body = client.get("/api/compare?urns=100140,138690").json()
school = body["comparison"]["100140"]
assert school["school_info"]["rwm_expected_pct"] == 79.0
assert school["yearly_data"][0]["year"] == LATEST
def test_each_school_gains_supplementary_blocks(client):
body = client.get("/api/compare?urns=100140,138690").json()
for urn in ("100140", "138690"):
school = body["comparison"][urn]
assert school["ofsted"]["grade_source"] == "graded"
assert school["census"]["fsm_pct"] == 29.8
assert school["admissions"]["second_preference_offers"] == 4
assert school["admissions_history"][0]["year"] == 202627
assert school["deprivation"]["idaci_decile"] == 4
def test_top_level_national_averages_and_benchmarks(client):
body = client.get("/api/compare?urns=100140,138690").json()
assert body["national_averages"]["year"] == LATEST
assert body["benchmarks"]["source"] == "state-school average (computed from our dataset)"
# weighted over equal cohorts of 72 and 86 = 79.0
assert body["benchmarks"]["primary"]["disadvantaged_rwm_expected_pct"] == 79.0
def test_supplementary_failure_degrades_not_500(client, monkeypatch):
from backend import app as app_module
def _boom(db, urns):
raise RuntimeError("marts unavailable")
monkeypatch.setattr(app_module, "get_supplementary_data_batch", _boom)
resp = client.get("/api/compare?urns=100140")
assert resp.status_code == 200
school = resp.json()["comparison"]["100140"]
assert school["ofsted"] is None
assert school["admissions_history"] == []
def test_national_averages_endpoint_exposes_gps_science(client):
body = client.get("/api/national-averages").json()
latest_primary_by_year = [e["primary"] for e in body["by_year"] if e["primary"]]
assert latest_primary_by_year, "expected official by_year rows from the stub"
assert latest_primary_by_year[-1]["gps_expected_pct"] == 72.0
assert latest_primary_by_year[-1]["science_expected_pct"] == 81.0
+95
View File
@@ -0,0 +1,95 @@
"""Tests for the GIAS code->name dictionaries (spec 2026-07-09).
The dictionaries are generated from the live GIAS bulk CSV by
pipeline/scripts/generate_gias_codes.py — these tests assert the module's
contract, key sentinel values the marts/UI depend on, and that the pipeline
copy has not drifted from the canonical backend module.
"""
import math
from pathlib import Path
from backend.gias_codes import (
ADMISSIONS_POLICY,
ESTABLISHMENT_STATUS,
OFFICIAL_SIXTH_FORM,
PHASE_OF_EDUCATION,
RELIGIOUS_CHARACTER,
SCHOOL_TYPE,
translate,
)
REPO = Path(__file__).resolve().parents[2]
def test_translate_known_code():
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
assert translate(open_code, ESTABLISHMENT_STATUS) == "Open"
def test_translate_unknown_code_degrades_gracefully():
assert translate(9999, ESTABLISHMENT_STATUS) == "Unknown (9999)"
def test_translate_none_and_nan_return_none():
assert translate(None, ESTABLISHMENT_STATUS) is None
assert translate(float("nan"), ESTABLISHMENT_STATUS) is None
def test_translate_accepts_float_codes():
# pd.read_sql yields float columns when NULLs are present
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
assert translate(float(open_code), ESTABLISHMENT_STATUS) == "Open"
def test_sentinel_names_present():
"""Names the marts/UI compare against must exist verbatim."""
assert "Open" in ESTABLISHMENT_STATUS.values()
assert "Open, but proposed to close" in ESTABLISHMENT_STATUS.values()
assert "Has a sixth form" in OFFICIAL_SIXTH_FORM.values()
assert "Primary" in PHASE_OF_EDUCATION.values()
assert "Secondary" in PHASE_OF_EDUCATION.values()
assert "Does not apply" in RELIGIOUS_CHARACTER.values()
assert all(len(d) > 0 for d in (
SCHOOL_TYPE, ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION,
OFFICIAL_SIXTH_FORM, RELIGIOUS_CHARACTER, ADMISSIONS_POLICY,
))
def test_pipeline_copy_is_identical():
canonical = (REPO / "backend" / "gias_codes.py").read_text()
copy = (REPO / "pipeline" / "scripts" / "gias_codes.py").read_text()
assert canonical == copy, (
"pipeline/scripts/gias_codes.py has drifted from backend/gias_codes.py — "
"regenerate with pipeline/scripts/generate_gias_codes.py and copy the file"
)
def test_seed_matches_dictionaries():
import csv
fields = {
"school_type": SCHOOL_TYPE,
"establishment_status": ESTABLISHMENT_STATUS,
"phase_of_education": PHASE_OF_EDUCATION,
"official_sixth_form": OFFICIAL_SIXTH_FORM,
"religious_character": RELIGIOUS_CHARACTER,
"admissions_policy": ADMISSIONS_POLICY,
}
seed_path = REPO / "pipeline" / "transform" / "seeds" / "gias_code_names.csv"
seed: dict[str, dict[int, str]] = {k: {} for k in fields}
with open(seed_path, newline="") as fh:
for row in csv.DictReader(fh):
seed[row["field"]][int(row["code"])] = row["name"]
assert seed == fields
def test_blank_name_sentinel_codes_map_to_empty_string():
"""GIAS carries codes whose (name) column is blank — e.g. ReligiousCharacter
99 (~4k schools) and AdmissionsPolicy 9 (~5.6k schools). The old name
pipeline served these as empty strings; the dictionaries must reproduce
that ("" is falsy, so UI tag heuristics stay silent) rather than letting
them hit the "Unknown (<code>)" path meant for genuinely new codes."""
assert RELIGIOUS_CHARACTER[99] == ""
assert ADMISSIONS_POLICY[9] == ""
assert translate(99, RELIGIOUS_CHARACTER) == ""
assert translate(9, ADMISSIONS_POLICY) == ""
+132
View File
@@ -0,0 +1,132 @@
"""API-boundary translation: marts now carry GIAS codes; the DataFrame the
rest of the backend sees must carry today's name strings."""
import numpy as np
import pandas as pd
from backend.data_loader import _missing_column_name, translate_gias_code_columns
from backend.gias_codes import ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION
def _code_for(mapping, name):
return next(c for c, n in mapping.items() if n == name)
def test_codes_become_todays_names():
df = pd.DataFrame([{
"urn": 1,
"phase_code": float(_code_for(PHASE_OF_EDUCATION, "Primary")),
"school_type_code": np.nan,
"status_code": float(_code_for(ESTABLISHMENT_STATUS, "Open, but proposed to close")),
"religious_character_code": np.nan,
"admissions_policy_code": np.nan,
}])
out = translate_gias_code_columns(df)
row = out.iloc[0]
assert row["phase"] == "Primary"
assert row["status"] == "Open, but proposed to close"
assert row["school_type"] is None
assert row["religious_denomination"] is None
assert row["admissions_policy"] is None
def test_unknown_code_degrades_not_blanks():
df = pd.DataFrame([{"urn": 1, "phase_code": 9999.0}])
out = translate_gias_code_columns(df)
assert out.iloc[0]["phase"] == "Unknown (9999)"
def test_missing_code_columns_are_a_noop():
"""Old-schema DataFrames (tests, pre-pipeline DBs) pass through untouched."""
df = pd.DataFrame([{"urn": 1, "phase": "Primary", "status": "Open"}])
out = translate_gias_code_columns(df)
assert out.iloc[0]["phase"] == "Primary"
assert out.iloc[0]["status"] == "Open"
def _fake_exc(orig_message):
"""A stand-in for sqlalchemy.exc.ProgrammingError: str(exc) embeds the
full SQL statement (deliberately containing every column name below, to
prove the matcher doesn't fall back to it), while .orig carries the real
DBAPI error message naming only the offending column."""
exc = Exception(
"SELECT s.phase_code, s.school_type_code, s.religious_character_code, "
"s.status_code, s.admissions_policy_code, s.has_sixth_form FROM ... "
f"[SQL: ...] (Background on this error at: https://...)"
)
exc.orig = Exception(orig_message) if orig_message is not None else None
return exc
def test_missing_column_name_quoted():
assert _missing_column_name(_fake_exc('column "phase_code" does not exist')) == "phase_code"
def test_missing_column_name_unquoted():
assert _missing_column_name(_fake_exc("column phase_code does not exist")) == "phase_code"
def test_missing_column_name_table_prefixed():
assert (
_missing_column_name(_fake_exc("column s.has_sixth_form does not exist"))
== "has_sixth_form"
)
def test_missing_column_name_no_match_returns_none():
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
def test_load_school_data_survives_premigration_marts(monkeypatch):
"""Real prod state until the nightly pipeline first rebuilds the mart with
the GIAS code columns: marts.dim_school still has the old name columns
(phase, school_type, religious_character, status, admissions_policy)
instead of the new *_code columns. The first query raises UndefinedColumn
on s.phase_code; load_school_data_as_dataframe must retry with the
legacy name-column query rather than swallow the error and return (and
then have load_school_data cache) an empty DataFrame."""
import sqlalchemy.exc
from backend import data_loader
data_loader._df_cache = None
data_loader._df_latest_cache = None
good_df = pd.DataFrame(
[
{
"urn": 1,
"school_name": "Legacy School",
"phase": "Primary",
"school_type": "Academy",
"status": "Open",
}
]
)
calls = []
def fake_read_sql(query, con):
calls.append(query)
if len(calls) == 1:
raise sqlalchemy.exc.ProgrammingError(
statement=str(data_loader._MAIN_QUERY),
params=None,
orig=Exception(
"(psycopg2.errors.UndefinedColumn) column s.phase_code "
"does not exist\nLINE 5: s.phase_code,"
),
)
return good_df.copy()
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
try:
df = data_loader.load_school_data_as_dataframe()
finally:
data_loader._df_cache = None
data_loader._df_latest_cache = None
assert len(calls) == 2, "must retry with the legacy name-column query variant"
assert calls[1] is data_loader._MAIN_QUERY_LEGACY_NAMES
assert not df.empty
assert df["phase"].iloc[0] == "Primary"
assert df["status"].iloc[0] == "Open"
@@ -0,0 +1,94 @@
"""_national_averages_payload reads persisted marts (computed at import
time) — it must never aggregate the dataframe. Both marts hold OFFICIAL
DfE figures, so a missing KS4 mart yields an empty secondary series —
never a computed stand-in the UI would mislabel as official."""
import numpy as np
import pandas as pd
import pytest
LATEST = 202425
def _df():
return pd.DataFrame(
[
dict(year=202324, attainment_8_score=40.0, rwm_expected_pct=np.nan),
dict(year=LATEST, attainment_8_score=50.0, rwm_expected_pct=np.nan),
dict(year=LATEST, attainment_8_score=30.0, rwm_expected_pct=np.nan),
dict(year=LATEST, attainment_8_score=np.nan, rwm_expected_pct=80.0),
]
)
class _Ks2Row:
year = LATEST
rwm_expected_pct = 62.1
gps_expected_pct = 72.0
class _Ks4Row:
year = LATEST
attainment_8_score = 46.5
progress_8_score = -0.02
class _StubSession:
"""Returns KS2 rows for the first query and KS4 rows for the second —
mirroring the payload's query order."""
def __init__(self):
self.calls = 0
def query(self, model):
self._model = model.__name__
return self
def order_by(self, *a):
return self
def all(self):
return [_Ks2Row()] if self._model == "Ks2NationalAverage" else [_Ks4Row()]
def close(self):
pass
class _Ks4MissingSession(_StubSession):
def all(self):
if self._model == "Ks4NationalAverage":
raise RuntimeError("relation does not exist")
return [_Ks2Row()]
def rollback(self):
pass
@pytest.fixture()
def payload(monkeypatch):
from backend import app as app_module
from backend import database as database_module
def _run(session_cls):
monkeypatch.setattr(database_module, "SessionLocal", session_cls)
return app_module._national_averages_payload(_df())
return _run
def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
body = payload(_StubSession)
# Mart value (46.5), NOT the dataframe mean of (50+30)/2 = 40.0
assert body["secondary"]["attainment_8_score"] == 46.5
assert body["primary"]["rwm_expected_pct"] == 62.1
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
def test_ks4_secondary_empty_when_mart_missing(payload):
# No computed stand-in: the UI labels national figures as official DfE
# data, so an empty mart must yield an empty secondary series.
body = payload(_Ks4MissingSession)
assert body["secondary"] == {}
assert all(not e["secondary"] for e in body["by_year"])
# The KS2 series is unaffected.
assert body["primary"]["rwm_expected_pct"] == 62.1
+45
View File
@@ -0,0 +1,45 @@
"""Report-card code translation uses the live-sampled Ofsted vocabulary
(pipeline/scripts/diagnose_compare_gaps.py, TASK 7 VALUE SAMPLE):
Exceptional / Strong standard / Expected standard / Needs attention /
Urgent improvement — never the consultation draft's 'Attention needed'."""
from backend.ofsted_codes import (
REPORT_CARD_GRADE_NAMES,
ofsted_page_url,
report_card_labels,
)
def test_scale_is_sampled_vocabulary():
assert REPORT_CARD_GRADE_NAMES == {
1: "Exceptional",
2: "Strong standard",
3: "Expected standard",
4: "Needs attention",
5: "Urgent improvement",
}
def test_labels_only_for_populated_areas_and_never_safeguarding():
ofsted = {
"rc_achievement": 2,
"rc_inclusion": 3,
"rc_attendance_behaviour": 4,
"rc_early_years": None,
"rc_safeguarding_met": True,
"overall_effectiveness": None,
}
labels = report_card_labels(ofsted)
assert labels == {
"rc_achievement": {"code": 2, "label": "Strong standard"},
"rc_inclusion": {"code": 3, "label": "Expected standard"},
"rc_attendance_behaviour": {"code": 4, "label": "Needs attention"},
}
def test_unknown_code_is_skipped_not_crashed():
assert report_card_labels({"rc_achievement": 9}) == {}
def test_provider_url():
assert ofsted_page_url(138690) == "https://reports.ofsted.gov.uk/provider/21/138690"
+71
View File
@@ -0,0 +1,71 @@
"""Regression tests for GET /api/schools/{urn}.
Schools with no performance rows (special post-16 institutions, sixth-form
centres, PRUs, brand-new schools) come back from the marts LEFT JOIN with
NaN in every numeric column. The endpoint must still serialize them — a NaN
that reaches Starlette's JSONResponse raises ValueError (allow_nan=False)
and the route 500s, which the frontend then renders as a 404.
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _no_results_school_df() -> pd.DataFrame:
"""One school row as produced by the marts query for a school with no
performance data: GIAS/location fields partly populated, every
results-linked column NaN (including year)."""
return pd.DataFrame(
[
{
"urn": 150275,
"school_name": "West London Performing Arts Academy",
"phase": "Secondary",
"school_type": "Special post 16 institution",
"trust_name": None,
"religious_denomination": "Does not apply",
"gender": None,
"age_range": "16-25",
"admissions_policy": None,
"capacity": np.nan,
"gias_total_pupils": np.nan,
"headteacher_name": None,
"website": None,
"ofsted_grade": np.nan,
"local_authority": "Ealing",
"address": "268 Northfield Avenue, London, W5 4UB",
"postcode": "W5 4UB",
"latitude": 51.4986,
"longitude": -0.3148,
"year": np.nan,
"total_pupils": np.nan,
"eligible_pupils": np.nan,
"rwm_expected_pct": np.nan,
}
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _no_results_school_df)
monkeypatch.setattr(
app_module, "get_supplementary_data", lambda db, urn: {}
)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_school_without_performance_rows_returns_200(client):
resp = client.get("/api/schools/150275")
assert resp.status_code == 200, resp.text
def test_nan_gias_fields_serialize_as_null(client):
info = client.get("/api/schools/150275").json()["school_info"]
assert info["capacity"] is None
assert info["total_pupils"] is None
assert info["school_name"] == "West London Performing Arts Academy"
+70
View File
@@ -0,0 +1,70 @@
"""Tests for GIAS establishment status exposure.
"Open, but proposed to close" schools are now kept by the dims; the API must
surface `status` on list items and school_info so the UI can render the
proposed-to-close marker (listing tag) and notice strip (detail page).
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
PROPOSED = "Open, but proposed to close"
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"age_range": "11-16",
"admissions_policy": None,
"has_sixth_form": False,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 800,
"rwm_expected_pct": np.nan,
"attainment_8_score": 48.0,
}
return pd.DataFrame(
[
{**base, "urn": 200001, "school_name": "Alpha Academy",
"status": "Open"},
{**base, "urn": 200002, "school_name": "Sarson High School",
"status": PROPOSED},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def test_list_payload_includes_status(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[200001]["status"] == "Open"
assert by_urn[200002]["status"] == PROPOSED
def test_detail_payload_includes_status(client):
resp = client.get("/api/schools/200002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["status"] == PROPOSED
+177
View File
@@ -0,0 +1,177 @@
"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).
The filter and payloads must use dim_school.has_sixth_form, not the old
age_range-contains-"18" substring heuristic. The key regression case is a
16-19 sixth-form college: flag true, but "16-19" contains no "18".
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
"""Latest-year snapshot rows as produced by load_latest_school_data."""
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"admissions_policy": None,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 1000,
"rwm_expected_pct": np.nan,
"attainment_8_score": 50.0,
}
return pd.DataFrame(
[
# 11-18 school WITH a registered sixth form
{**base, "urn": 100001, "school_name": "Alpha High",
"age_range": "11-18", "has_sixth_form": True},
# 16-19 college: old heuristic said NO ("16-19" has no "18"),
# GIAS flag says YES — must appear in the yes-filter results
{**base, "urn": 100002, "school_name": "Beta Sixth Form College",
"age_range": "16-19", "has_sixth_form": True},
# 11-18 age range on paper but NO registered sixth form:
# old heuristic said YES, GIAS flag says NO
{**base, "urn": 100003, "school_name": "Gamma Academy",
"age_range": "11-18", "has_sixth_form": False},
# Missing flag (pipeline not yet re-run) — must not crash,
# must not match the yes-filter
{**base, "urn": 100004, "school_name": "Delta School",
"age_range": "11-16", "has_sixth_form": None},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def _urns(resp):
return sorted(s["urn"] for s in resp.json()["schools"])
def test_filter_yes_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=yes")
assert resp.status_code == 200, resp.text
# 16-19 college included; 11-18-without-sixth-form excluded
assert _urns(resp) == [100001, 100002]
def test_filter_no_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=no")
assert resp.status_code == 200, resp.text
# Gamma (flag false) and Delta (flag missing => not true)
assert _urns(resp) == [100003, 100004]
def test_list_payload_includes_flag(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[100002]["has_sixth_form"] is True
assert by_urn[100003]["has_sixth_form"] is False
assert by_urn[100004]["has_sixth_form"] is None
def test_detail_payload_includes_flag(client):
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
def test_detail_payload_serializes_numpy_bool(monkeypatch):
"""Once the pipeline has run, has_sixth_form is a real bool dtype column
(dbt not_null test guarantees no NULLs), so row access yields
numpy.bool_ rather than a Python bool. convert_to_native must handle it —
otherwise FastAPI's jsonable_encoder raises ValueError and the detail
endpoint 500s (C2)."""
from backend import app as app_module
df = _schools_df()
# Drop the row with a None flag — this fixture models the post-pipeline
# state where the column is a genuine, fully-populated bool dtype.
df = df[df["has_sixth_form"].notna()].reset_index(drop=True)
df["has_sixth_form"] = df["has_sixth_form"].astype(bool)
assert df["has_sixth_form"].dtype == bool
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
client = TestClient(app_module.app, raise_server_exceptions=False)
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
def test_load_school_data_survives_missing_has_sixth_form_column(monkeypatch):
"""Real prod state until the nightly pipeline first rebuilds the mart:
marts.dim_school lacks has_sixth_form entirely. The first query raises
UndefinedColumn; load_school_data_as_dataframe must retry without the
column (synthesizing it as None) rather than swallow the error and
return (and then have load_school_data cache) an empty DataFrame (C1)."""
import sqlalchemy.exc
from backend import data_loader
data_loader._df_cache = None
data_loader._df_latest_cache = None
good_df = pd.DataFrame(
[
{
"urn": 1,
"school_name": "Fallback School",
"school_type": "Academy",
"has_sixth_form": None,
}
]
)
calls = []
def fake_read_sql(query, con):
calls.append(query)
if len(calls) == 1:
# The statement text still contains phase_code, school_type_code,
# etc. (it's the full _MAIN_QUERY SELECT list) — that's exactly
# the collision this test guards against: matching must be done
# against exc.orig (the DBAPI error), not str(exc)/the statement.
raise sqlalchemy.exc.ProgrammingError(
statement=str(data_loader._MAIN_QUERY),
params=None,
orig=Exception(
"(psycopg2.errors.UndefinedColumn) column s.has_sixth_form "
"does not exist"
),
)
return good_df.copy()
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
try:
df = data_loader.load_school_data_as_dataframe()
finally:
data_loader._df_cache = None
data_loader._df_latest_cache = None
assert len(calls) == 2, "must retry with the no-sixth-form query variant"
assert calls[1] is data_loader._MAIN_QUERY_NO_SIXTH_FORM
assert not df.empty
assert "has_sixth_form" in df.columns
assert df["has_sixth_form"].iloc[0] is None
+111
View File
@@ -0,0 +1,111 @@
"""get_supplementary_data_batch fetches one query per table for all URNs
(not ~5 per school) and returns the same per-URN block shape as the
single-URN function, picking the latest row per URN where relevant."""
import types
from backend import data_loader
from backend.data_loader import get_supplementary_data_batch
class _FakeQuery:
"""Records that a query ran and serves canned rows filtered by an in-list."""
def __init__(self, recorder, model_name, rows):
self._rec = recorder
self._model = model_name
self._rows = rows
def filter(self, *args, **kwargs):
return self
def order_by(self, *args, **kwargs):
return self
def all(self):
self._rec.append(self._model)
return self._rows
def first(self):
self._rec.append(self._model)
return self._rows[0] if self._rows else None
class _FakeSession:
def __init__(self, rows_by_model):
self.rows_by_model = rows_by_model
self.queries: list[str] = []
def query(self, model):
name = model.__name__
return _FakeQuery(self.queries, name, self.rows_by_model.get(name, []))
def rollback(self):
pass
def _ofsted_row(urn, date, oe):
base = {f: None for f in (
"framework", "inspection_type", "quality_of_education", "behaviour_attitudes",
"personal_development", "leadership_management", "early_years_provision",
"sixth_form_provision", "ungraded_outcome", "ungraded_grade",
"rc_safeguarding_met", "rc_inclusion", "rc_curriculum_teaching", "rc_achievement",
"rc_attendance_behaviour", "rc_personal_development", "rc_leadership_governance",
"rc_early_years", "rc_sixth_form", "report_url",
)}
base.update(urn=urn, inspection_date=types.SimpleNamespace(isoformat=lambda: date),
overall_effectiveness=oe, grade_source=None)
return types.SimpleNamespace(**base)
def _adm_row(urn, year):
return types.SimpleNamespace(
urn=urn, year=year, school_phase="Primary", places_offered=100,
total_applications=200, first_preference_applications=150,
first_preference_offers=140, first_preference_offer_pct=93.3,
oversubscription_ratio=1.5, oversubscribed=True,
total_offers=100, second_preference_offers=5, third_preference_offers=2,
cross_la_applications=10, cross_la_offers=3,
)
def test_one_query_per_table_and_latest_row_per_urn():
rows = {
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
"FactOfstedInspection": [
_ofsted_row(1, "2023-01-01", 2),
_ofsted_row(1, "2019-01-01", 3),
_ofsted_row(2, "2021-06-01", 1),
],
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
"FactPupilCharacteristics": [],
"FactDeprivation": [],
"FactFinance": [],
}
session = _FakeSession(rows)
out = get_supplementary_data_batch(session, [1, 2])
# Exactly one query per table — five total, regardless of two URNs.
assert sorted(session.queries) == [
"FactAdmissions", "FactDeprivation", "FactFinance",
"FactOfstedInspection", "FactPupilCharacteristics",
]
# Latest Ofsted kept per URN
assert out[1]["ofsted"]["overall_effectiveness"] == 2
assert out[2]["ofsted"]["overall_effectiveness"] == 1
# Admissions history grouped per URN, latest exposed as `admissions`
assert [r["year"] for r in out[1]["admissions_history"]] == [202526, 202627]
assert out[1]["admissions"]["year"] == 202627
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
# Empty tables degrade to the null block, not a crash
assert out[1]["census"] is None and out[1]["deprivation"] is None
def test_single_wrapper_matches_batch(monkeypatch):
session = _FakeSession({"FactOfstedInspection": [_ofsted_row(5, "2022-01-01", 2)]})
single = data_loader.get_supplementary_data(session, 5)
assert single["ofsted"]["overall_effectiveness"] == 2
assert single["admissions_history"] == []
@@ -0,0 +1,85 @@
"""Supplementary-block enrichment for the compare redesign: report-card
labels, provider-page URL, graded-vs-carried-forward provenance, and the
admissions preference/cross-LA detail promoted in the data-foundation PR."""
import types
from datetime import date
from backend.data_loader import _admissions_row_dict, _ofsted_block
def _row(**kw):
base = dict(
framework="RC", inspection_date=None, inspection_type=None,
overall_effectiveness=None, quality_of_education=None,
behaviour_attitudes=None, personal_development=None,
leadership_management=None, early_years_provision=None,
sixth_form_provision=None, ungraded_outcome=None, ungraded_grade=None,
rc_safeguarding_met=None, rc_inclusion=None, rc_curriculum_teaching=None,
rc_achievement=None, rc_attendance_behaviour=None,
rc_personal_development=None, rc_leadership_governance=None,
rc_early_years=None, rc_sixth_form=None, report_url=None,
)
base.update(kw)
return types.SimpleNamespace(**base)
def test_report_card_block_and_provider_url():
o = _row(rc_achievement=2, rc_inclusion=3, rc_safeguarding_met=True)
block = _ofsted_block(o, urn=100140)
assert block["report_card"]["rc_achievement"]["label"] == "Strong standard"
assert "rc_safeguarding_met" not in block["report_card"]
assert block["rc_safeguarding_met"] is True
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140"
def test_grade_source_graded_vs_carried_forward():
assert _ofsted_block(_row(overall_effectiveness=1), urn=1)["grade_source"] == "graded"
carried = _ofsted_block(_row(ungraded_grade=2), urn=1)
assert carried["grade_source"] == "ungraded_carried_forward"
assert carried["overall_effectiveness"] == 2
assert _ofsted_block(_row(), urn=1)["grade_source"] is None
def test_ofsted_block_carries_rc_inspection_date():
o = _row(
ungraded_grade=2,
rc_achievement=1,
rc_inspection_date=date(2026, 2, 3),
inspection_date=date(2021, 10, 7),
)
block = _ofsted_block(o, urn=138690)
assert block["rc_inspection_date"] == "2026-02-03"
# The legacy inspection date is still present, unchanged.
assert block["inspection_date"] == "2021-10-07"
def test_ofsted_block_rc_inspection_date_none_when_absent():
o = _row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
block = _ofsted_block(o, urn=136276)
assert block["rc_inspection_date"] is None
def test_ofsted_block_keeps_existing_keys():
block = _ofsted_block(_row(overall_effectiveness=2, quality_of_education=2), urn=1)
for key in ("framework", "inspection_date", "overall_effectiveness",
"quality_of_education", "rc_inclusion", "report_url"):
assert key in block
def test_admissions_row_new_fields():
a = types.SimpleNamespace(
year=202627, school_phase="Primary", places_offered=80,
total_applications=185, first_preference_applications=74,
first_preference_offers=74, first_preference_offer_pct=100.0,
oversubscription_ratio=0.925, oversubscribed=False,
total_offers=80, second_preference_offers=4, third_preference_offers=2,
cross_la_applications=12, cross_la_offers=3,
)
d = _admissions_row_dict(a)
for k in ("total_offers", "second_preference_offers", "third_preference_offers",
"cross_la_applications", "cross_la_offers"):
assert d[k] == getattr(a, k)
# Existing keys unchanged
assert d["first_preference_offer_pct"] == 100.0
assert d["oversubscribed"] is False
+2
View File
@@ -11,6 +11,8 @@ def convert_to_native(value: Any) -> Any:
"""Convert numpy types to native Python types for JSON serialization.""" """Convert numpy types to native Python types for JSON serialization."""
if pd.isna(value): if pd.isna(value):
return None return None
if isinstance(value, np.bool_):
return bool(value)
if isinstance(value, (np.integer,)): if isinstance(value, (np.integer,)):
return int(value) return int(value)
if isinstance(value, (np.floating,)): if isinstance(value, (np.floating,)):
+2 -1
View File
@@ -13,7 +13,7 @@ WHEN TO BUMP:
""" """
# Current schema version - increment when models change # Current schema version - increment when models change
SCHEMA_VERSION = 5 SCHEMA_VERSION = 6
# Changelog for documentation # Changelog for documentation
SCHEMA_CHANGELOG = { SCHEMA_CHANGELOG = {
@@ -22,4 +22,5 @@ SCHEMA_CHANGELOG = {
3: "Added supplementary data tables: ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance; GIAS columns on schools", 3: "Added supplementary data tables: ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance; GIAS columns on schools",
4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)", 4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)",
5: "Apply ALTER TABLE additions for RC columns missed by create_all on existing tables", 5: "Apply ALTER TABLE additions for RC columns missed by create_all on existing tables",
6: "Removed the Ofsted Parent View feature: dropped fact_parent_view table and model",
} }
+8 -4
View File
@@ -112,11 +112,15 @@ Full details in `docs/DEPLOY.md`. The short version:
- **Never push to `main` directly.** Work on a feature branch and open a PR; - **Never push to `main` directly.** Work on a feature branch and open a PR;
branch protection requires the PR checks (typecheck, tests, builds, AI review) branch protection requires the PR checks (typecheck, tests, builds, AI review)
to pass before merge. to pass before merge.
- Merging to `main` deploys automatically: images are built once, deployed to - Merging to `main` deploys automatically **to staging only**: images are
the **staging** Portainer stack, verified by the Playwright journeys in built once, deployed to the staging Portainer stack, and verified by the
`e2e/`, and only then retagged `:prod` and rolled out to production. Playwright journeys in `e2e/`. Production is a second, manual approval:
the "Promote to Production (manual)" workflow in Gitea Actions, run after
testing the feature on staging. It refuses commits whose staging E2E gate
isn't green. Never trigger it yourself — promotion is the human's call.
- If you change user-facing behaviour, update or extend the `e2e/` journey - If you change user-facing behaviour, update or extend the `e2e/` journey
tests in the same PR — they are the promotion gate. tests in the same PR — they gate whether staging is fit for human testing
and whether a commit is promotable.
## Recent Changes ## Recent Changes
+37 -17
View File
@@ -1,41 +1,61 @@
# SDLC & Deployment Pipeline # SDLC & Deployment Pipeline
SchoolCompare uses a fully automated staging → production pipeline on Gitea SchoolCompare uses a two-stage deploy model on Gitea Actions with two human
Actions. AI writes the code on feature branches; the pipeline verifies every approvals. AI writes the code on feature branches; the first approval merges
change on a staging environment before promoting the exact same images to the PR, which deploys to staging and runs the E2E gate; the second approval —
production. Human input is directional only: feature requests, PR review if after manual testing on staging — promotes the exact same images to
desired, and intervention when a gate fails. production via a manual workflow.
## The flow ## The flow
``` ```
feature branch (AI-authored) feature branch (AI-authored)
│ PR to main │ PR to main ← approval #1
PR checks (.gitea/workflows/pr-checks.yml) PR checks (.gitea/workflows/pr-checks.yml)
typecheck + unit tests + backend smoke + image builds (no push) typecheck + unit tests + backend smoke + image builds (no push)
+ Claude code review posted as a PR comment (severe findings fail the check) + Claude code review posted as a PR comment (severe findings fail the check)
│ merge (branch protection requires green checks) │ merge (branch protection requires green checks)
Deploy pipeline (.gitea/workflows/deploy.yml) Stage pipeline (.gitea/workflows/deploy.yml) — automatic
1. build & push images → tags sha-<sha>, staging 1. build & push images → tags sha-<sha>, staging
2. staging Portainer webhook → wait for staging health 2. staging Portainer webhook → wait for staging health
3. Playwright E2E journeys against staging 3. Playwright E2E journeys against staging ← gate before human testing
4. retag sha-<sha> → :prod (same bytes — build once, promote the image)
Manual testing on staging (stx.schoolcompare.co.uk)
│ Actions → "Promote to Production (manual)" ← approval #2
Promote pipeline (.gitea/workflows/promote.yml) — manual dispatch
1. resolve target sha (input, or latest main if empty)
2. REFUSE unless that commit's "E2E Journeys against Staging" status is green
3. retag sha-<sha> → :prod (same bytes — build once, promote the image)
previous :prod saved as :prod-previous previous :prod saved as :prod-previous
5. prod Portainer webhook → wait for prod health 4. prod Portainer webhook → wait for prod health
``` ```
Key principle: **build once, promote the exact image**. Production pins `:prod`, Key principle: **build once, promote the exact image**. Production pins `:prod`,
which only moves after the E2E gate passes on staging. Nothing tags `:latest` which only moves when a human runs the promote workflow — and the workflow
only accepts commits that passed the staging E2E gate. Nothing tags `:latest`
anymore. anymore.
## Branch & PR workflow ## Branch & PR workflow
- `main` is protected: no direct pushes, PRs require green status checks. - `main` is protected: no direct pushes, PRs require green status checks.
- All work (human or AI) happens on feature branches → PR to `main`. - All work (human or AI) happens on feature branches → PR to `main`.
- Merging to `main` **is** the release action. If staging or the E2E gate - Merging to `main` releases **to staging only**. Production moves only on
fails, production is untouched. the second approval. If staging or the E2E gate fails, fix forward —
production is untouched either way.
## Promotion granularity
Staging always runs the latest `main`. Promoting approves a *state of main*,
not a single PR — if two PRs merged since the last promotion, they ship
together. Test staging accordingly. To promote an older state, pass its
commit SHA to the promote workflow (its images must still exist in the
registry).
Staging quirk for manual testing: external `/api` is broken at the staging
proxy — exercise API endpoints from the host, not via the public staging URL.
## Environments ## Environments
@@ -79,8 +99,7 @@ fail the E2E gate. That's the point: staging absorbs the risk.
5. **Bootstrap staging data via Airflow** (no prod dump — staging populates 5. **Bootstrap staging data via Airflow** (no prod dump — staging populates
itself from source, exercising the pipeline image end-to-end): itself from source, exercising the pipeline image end-to-end):
- Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order: - Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order:
`school_data_daily`, `school_data_monthly_ofsted`, `school_data_daily`, `school_data_monthly_ofsted`, then the manual-schedule
`school_data_monthly_parent_view`, then the manual-schedule
`school_data_annual_ees` and `school_data_annual_idaci`. `school_data_annual_ees` and `school_data_annual_idaci`.
- First runs download from government sources (GIAS, Ofsted, EES, IDACI), - First runs download from government sources (GIAS, Ofsted, EES, IDACI),
run dbt, and sync Typesense — expect the initial backfill to take a while. run dbt, and sync Typesense — expect the initial backfill to take a while.
@@ -93,8 +112,9 @@ fail the E2E gate. That's the point: staging absorbs the risk.
## Rollback ## Rollback
Every promotion first re-points `:prod-previous` at the outgoing `:prod`. Re-run "Promote to Production (manual)" with the SHA of the last good commit
To roll back: (fastest, fully gated), or manually re-point the tags — every promotion first
saves the outgoing `:prod` as `:prod-previous`:
```bash ```bash
for img in backend frontend pipeline; do for img in backend frontend pipeline; do
@@ -0,0 +1,560 @@
# GIAS OfficialSixthForm Flag Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ingest GIAS's authoritative `OfficialSixthForm` flag into `marts.dim_school.has_sixth_form` and replace every `age_range contains "18"` heuristic in the backend and frontend with it.
**Architecture:** Data flows tap → raw → dbt staging → dbt mart → backend SQL → API payload → Next.js components. The GIAS Singer tap must declare the new CSV column (target-postgres only persists declared columns); the dbt staging model renames it; `dim_school` derives a boolean (with a statutory-age fallback for blank GIAS values); the backend exposes it on list + detail payloads and uses it for the `has_sixth_form=yes|no` filter; the frontend badge/note/filter-labels switch from the age-range substring check to the flag.
**Tech Stack:** Singer SDK (tap), dbt (Postgres), FastAPI + pandas, Next.js + TypeScript, pytest, Jest/RTL.
**Spec:** `docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md` §3.
## Global Constraints
- A school **has a sixth form** iff GIAS `OfficialSixthForm (name)` = `"Has a sixth form"`. `"Does not have a sixth form"` and `"Not applicable"` → false. Blank/NULL (rare) → fall back to `statutory_high_age >= 18`.
- The public API filter parameter stays `has_sixth_form=yes|no` (unchanged contract).
- Filter dropdown labels must drop the age-range parentheticals: "With sixth form" / "Without sixth form" (sixth form ≠ age range).
- Never push to `main`; work stays on branch `feat/gias-sixth-form-flag` (create from `docs/exam-phase-taxonomy` so the spec is included, or from `main` if that branch has merged).
- The dbt models cannot be run locally (no pipeline DB); dbt changes are verified by review + `python -c` schema asserts + existing CI. Do NOT attempt to start a local server.
- The backend marts tables are dbt `table` materializations — rebuilt on every pipeline run, so **no ALTER TABLE migration is needed** for `marts.dim_school`.
- Deployment ordering: the tap must run before dbt on the first pipeline run after deploy (this is already the DAG order: extract → transform). Until that run happens, `has_sixth_form` is absent from the DB; the backend must treat a missing column as "flag false / fallback", never crash.
---
### Task 1: Ingest `OfficialSixthForm (name)` — tap schema + dbt staging
**Files:**
- Modify: `pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py:31-66` (Singer schema)
- Modify: `pipeline/transform/models/staging/stg_gias_establishments.sql` (add renamed column)
**Interfaces:**
- Produces: raw column `"OfficialSixthForm (name)"` in `raw.gias_establishments`; staging column `official_sixth_form` (text: `Has a sixth form` / `Does not have a sixth form` / `Not applicable` / NULL) consumed by Task 2.
- [ ] **Step 1: Add the property to the Singer schema**
In `tap.py`, inside `GIASEstablishmentsStream.schema = th.PropertiesList(...)`, add after the `th.Property("PhaseOfEducation (name)", th.StringType),` line:
```python
th.Property("OfficialSixthForm (name)", th.StringType),
```
- [ ] **Step 2: Verify the tap module still imports and declares the column**
Run:
```bash
cd /Users/tudor/projects/school_compare/pipeline/plugins/extractors/tap-uk-gias && \
python3 -c "
import ast, sys
src = open('tap_uk_gias/tap.py').read()
ast.parse(src)
assert '\"OfficialSixthForm (name)\"' in src.replace(\"'\", '\"')
print('OK: tap declares OfficialSixthForm (name)')
"
```
Expected: `OK: tap declares OfficialSixthForm (name)`
(Uses `ast.parse` instead of importing because `singer_sdk` is not installed locally.)
- [ ] **Step 3: Add the column to the staging model**
In `stg_gias_establishments.sql`, in the `renamed` CTE, add after the `"PhaseOfEducation (name)" as phase,` line:
```sql
nullif(trim("OfficialSixthForm (name)"), '') as official_sixth_form,
```
- [ ] **Step 4: Sanity-check the SQL edit**
Run:
```bash
grep -n "official_sixth_form" /Users/tudor/projects/school_compare/pipeline/transform/models/staging/stg_gias_establishments.sql
```
Expected: one line showing the new column inside the `renamed` CTE (before `from source`).
- [ ] **Step 5: Commit**
```bash
git add pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py pipeline/transform/models/staging/stg_gias_establishments.sql
git commit -m "feat(pipeline): ingest GIAS OfficialSixthForm into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: Derive `dim_school.has_sixth_form` (dbt mart + schema tests + SQLAlchemy model)
**Files:**
- Modify: `pipeline/transform/models/marts/dim_school.sql` (add derived column)
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (document + test the column)
- Modify: `backend/models.py:13-38` (`DimSchool` — add column)
**Interfaces:**
- Consumes: `official_sixth_form` text column from Task 1's staging model.
- Produces: `marts.dim_school.has_sixth_form boolean not null`, and `DimSchool.has_sixth_form = Column(Boolean)` for the backend. Task 3 selects it as `s.has_sixth_form`.
- [ ] **Step 1: Add the derived column to `dim_school.sql`**
In the `select`, add after the `s.age_range` line (`s.statutory_low_age || '-' || s.statutory_high_age as age_range,`):
```sql
-- Authoritative sixth-form flag (spec §3): GIAS OfficialSixthForm.
-- "Not applicable" (nurseries, primaries, PRUs) => false. Blank GIAS
-- value (rare, new establishments) falls back to the statutory age range.
case
when s.official_sixth_form = 'Has a sixth form' then true
when s.official_sixth_form in ('Does not have a sixth form', 'Not applicable') then false
else coalesce(s.statutory_high_age >= 18, false)
end as has_sixth_form,
```
- [ ] **Step 2: Add schema documentation + tests in `_marts_schema.yml`**
Under `- name: dim_school``columns:`, add after the `phase` column block:
```yaml
- name: has_sixth_form
description: >
Authoritative sixth-form flag from GIAS OfficialSixthForm.
"Has a sixth form" => true; "Does not have a sixth form" and
"Not applicable" => false; blank GIAS value falls back to
statutory_high_age >= 18. Replaces the age_range-contains-"18"
heuristic (spec 2026-07-07 §3).
tests:
- not_null
- accepted_values:
values: [true, false]
```
- [ ] **Step 3: Add the column to the `DimSchool` SQLAlchemy model**
In `backend/models.py`, in `class DimSchool`, add after `age_range = Column(String(20))`:
```python
has_sixth_form = Column(Boolean)
```
- [ ] **Step 4: Verify SQL/YAML/Python all parse**
Run:
```bash
cd /Users/tudor/projects/school_compare && \
python3 -c "
import yaml
y = yaml.safe_load(open('pipeline/transform/models/marts/_marts_schema.yml'))
dim = [m for m in y['models'] if m['name'] == 'dim_school'][0]
cols = [c['name'] for c in dim['columns']]
assert 'has_sixth_form' in cols, cols
print('OK: schema yml documents has_sixth_form')
" && \
grep -c "has_sixth_form" pipeline/transform/models/marts/dim_school.sql && \
python3 -c "import ast; ast.parse(open('backend/models.py').read()); print('OK: models.py parses')"
```
Expected: `OK: schema yml documents has_sixth_form`, grep count `>= 1`, `OK: models.py parses`.
- [ ] **Step 5: Commit**
```bash
git add pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/_marts_schema.yml backend/models.py
git commit -m "feat(pipeline): derive dim_school.has_sixth_form from GIAS flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: Backend — expose `has_sixth_form` and replace the filter heuristic
**Files:**
- Modify: `backend/data_loader.py:117-215` (`_MAIN_QUERY` — select the column)
- Modify: `backend/schemas.py:536-553` (`SCHOOL_COLUMNS` — include in list payloads)
- Modify: `backend/app.py:419-422` (filter) and `backend/app.py:589-610` (detail `school_info`)
- Test: `backend/tests/test_sixth_form_flag.py` (new)
**Interfaces:**
- Consumes: `marts.dim_school.has_sixth_form` (Task 2).
- Produces: `has_sixth_form: bool | null` field on `GET /api/schools` items and on `GET /api/schools/{urn}``school_info`. Filter `GET /api/schools?has_sixth_form=yes|no` now driven by the flag. Frontend (Task 4) reads `school.has_sixth_form`.
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_sixth_form_flag.py`:
```python
"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).
The filter and payloads must use dim_school.has_sixth_form, not the old
age_range-contains-"18" substring heuristic. The key regression case is a
16-19 sixth-form college: flag true, but "16-19" contains no "18".
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
"""Latest-year snapshot rows as produced by load_latest_school_data."""
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"admissions_policy": None,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 1000,
"rwm_expected_pct": np.nan,
"attainment_8_score": 50.0,
}
return pd.DataFrame(
[
# 11-18 school WITH a registered sixth form
{**base, "urn": 100001, "school_name": "Alpha High",
"age_range": "11-18", "has_sixth_form": True},
# 16-19 college: old heuristic said NO ("16-19" has no "18"),
# GIAS flag says YES — must appear in the yes-filter results
{**base, "urn": 100002, "school_name": "Beta Sixth Form College",
"age_range": "16-19", "has_sixth_form": True},
# 11-18 age range on paper but NO registered sixth form:
# old heuristic said YES, GIAS flag says NO
{**base, "urn": 100003, "school_name": "Gamma Academy",
"age_range": "11-18", "has_sixth_form": False},
# Missing flag (pipeline not yet re-run) — must not crash,
# must not match the yes-filter
{**base, "urn": 100004, "school_name": "Delta School",
"age_range": "11-16", "has_sixth_form": None},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def _urns(resp):
return sorted(s["urn"] for s in resp.json()["schools"])
def test_filter_yes_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=yes")
assert resp.status_code == 200, resp.text
# 16-19 college included; 11-18-without-sixth-form excluded
assert _urns(resp) == [100001, 100002]
def test_filter_no_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=no")
assert resp.status_code == 200, resp.text
# Gamma (flag false) and Delta (flag missing => not true)
assert _urns(resp) == [100003, 100004]
def test_list_payload_includes_flag(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[100002]["has_sixth_form"] is True
assert by_urn[100003]["has_sixth_form"] is False
assert by_urn[100004]["has_sixth_form"] is None
def test_detail_payload_includes_flag(client):
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests/test_sixth_form_flag.py -v`
Expected: FAIL — `test_filter_yes_uses_flag_not_age_range` asserts `[100001, 100002]` but the age-range heuristic returns `[100001, 100003]`; the payload tests fail with `KeyError: 'has_sixth_form'`.
- [ ] **Step 3: Select the column in `_MAIN_QUERY`**
In `backend/data_loader.py`, in `_MAIN_QUERY`, add after `s.age_range,`:
```sql
s.has_sixth_form,
```
- [ ] **Step 4: Include it in list payloads**
In `backend/schemas.py`, in `SCHOOL_COLUMNS`, add after `"age_range",`:
```python
"has_sixth_form",
```
(`app.py` builds list responses from `SCHOOL_COLUMNS ∩ df.columns`, so a DB that predates the pipeline re-run simply omits the field — no crash.)
- [ ] **Step 5: Replace the filter heuristic in `app.py`**
Replace lines 419-422:
```python
if has_sixth_form == "yes":
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
elif has_sixth_form == "no":
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
```
with:
```python
# GIAS OfficialSixthForm flag (dim_school.has_sixth_form). NULL (flag not
# yet populated by the pipeline) is treated as "no sixth form".
if has_sixth_form in ("yes", "no"):
if "has_sixth_form" in df_latest.columns:
flag = df_latest["has_sixth_form"].eq(True)
else: # DB predates the pipeline re-run — fall back to age range
flag = df_latest["age_range"].str.contains("18", na=False)
df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]
```
- [ ] **Step 6: Add the flag to the detail payload**
In `backend/app.py` `school_info` dict (line ~598), add after `"age_range": latest.get("age_range", ""),`:
```python
"has_sixth_form": latest.get("has_sixth_form"),
```
(`convert_to_native` already maps NaN/None → null and numpy bools → bool.)
- [ ] **Step 7: Run the new tests**
Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests/test_sixth_form_flag.py -v`
Expected: 4 passed.
- [ ] **Step 8: Run the full backend suite**
Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests -v`
Expected: all pass (the pre-existing `test_school_details.py` df has no `has_sixth_form` column — `latest.get()` returns None, serialized as null).
- [ ] **Step 9: Commit**
```bash
git add backend/data_loader.py backend/schemas.py backend/app.py backend/tests/test_sixth_form_flag.py
git commit -m "feat(api): drive has_sixth_form filter and payloads from GIAS flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Frontend — badge, note, row tag, and filter labels use the flag
**Files:**
- Modify: `nextjs-app/lib/types.ts:10-30` (`School` interface)
- Modify: `nextjs-app/components/SecondarySchoolDetailView.tsx:101` (badge + coming-soon note)
- Modify: `nextjs-app/components/SecondarySchoolRow.tsx:25-27` (row tag)
- Modify: `nextjs-app/components/FilterBar.tsx:370-372` (labels only — param name unchanged)
- Test: `nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx` (new)
**Interfaces:**
- Consumes: `has_sixth_form: boolean | null` on both list items and `school_info` (Task 3; both are typed as `School`).
- Produces: no new exports — behavior change only.
- [ ] **Step 1: Write the failing test**
Create `nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx`:
```tsx
/**
* SecondarySchoolRow — sixth-form tag must come from the GIAS
* has_sixth_form flag, not the age_range-contains-"18" heuristic.
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { SecondarySchoolRow } from '@/components/SecondarySchoolRow';
import type { School } from '@/lib/types';
const base = {
urn: 100002,
school_name: 'Beta Sixth Form College',
local_authority: 'Testshire',
school_type: 'Academy',
phase: 'Secondary',
gender: 'Mixed',
attainment_8_score: 50.0,
} as unknown as School;
describe('SecondarySchoolRow sixth-form tag', () => {
it('shows the tag for a 16-19 college with the GIAS flag set', () => {
render(
<SecondarySchoolRow
school={{ ...base, age_range: '16-19', has_sixth_form: true }}
/>,
);
expect(screen.getByText('Sixth form')).toBeInTheDocument();
});
it('hides the tag for an 11-18 school without a registered sixth form', () => {
render(
<SecondarySchoolRow
school={{ ...base, age_range: '11-18', has_sixth_form: false }}
/>,
);
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
});
it('hides the tag when the flag is missing (pipeline not yet re-run)', () => {
render(
<SecondarySchoolRow school={{ ...base, age_range: '11-18' }} />,
);
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx jest __tests__/components/SecondarySchoolRow.test.tsx`
Expected: FAIL — first test can't find "Sixth form" ("16-19" fails the substring check), second test finds an unexpected "Sixth form" tag. (If TS complains that `has_sixth_form` is not on `School`, that is the same failure — proceed.)
- [ ] **Step 3: Add the field to the `School` type**
In `nextjs-app/lib/types.ts`, in `export interface School`, add after `age_range: string | null;`:
```ts
has_sixth_form?: boolean | null;
```
- [ ] **Step 4: Switch `SecondarySchoolRow` to the flag**
Replace the helper at `SecondarySchoolRow.tsx:25-27`:
```ts
function hasSixthForm(school: School): boolean {
return school.age_range?.includes('18') ?? false;
}
```
with:
```ts
function hasSixthForm(school: School): boolean {
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
return school.has_sixth_form ?? false;
}
```
- [ ] **Step 5: Switch `SecondarySchoolDetailView` to the flag**
Replace line 101:
```ts
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
```
with:
```ts
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
```
(This drives both the header "Sixth form" badge at line ~230 and the "Post-16 destination data coming soon" note at line ~715 — no changes needed there.)
- [ ] **Step 6: Fix the filter labels in `FilterBar.tsx`**
Replace:
```tsx
<option value="yes">With sixth form (11-18)</option>
<option value="no">Without sixth form (11-16)</option>
```
with:
```tsx
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
```
- [ ] **Step 7: Run the new test and verify it passes**
Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx jest __tests__/components/SecondarySchoolRow.test.tsx`
Expected: 3 passed.
- [ ] **Step 8: Run the full frontend checks**
Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx tsc --noEmit && npx jest`
Expected: typecheck clean, all Jest suites pass.
- [ ] **Step 9: Verify no heuristic remains**
Run:
```bash
grep -rn "includes('18')\|contains(\"18\")" /Users/tudor/projects/school_compare/nextjs-app/components /Users/tudor/projects/school_compare/backend --include="*.tsx" --include="*.ts" --include="*.py" | grep -v test
```
Expected: only the documented fallback inside `app.py` (DB-predates-pipeline branch); no other hits.
- [ ] **Step 10: Commit**
```bash
git add nextjs-app/lib/types.ts nextjs-app/components/SecondarySchoolRow.tsx nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/components/FilterBar.tsx nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx
git commit -m "feat(ui): sixth-form badge, note and filter labels use GIAS flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 5: Update the spec status + PR
**Files:**
- Modify: `docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md` (§3 "Pipeline change (future work)" → implemented)
**Interfaces:**
- Consumes: everything above merged into the branch.
- Produces: PR ready for review; e2e journeys are the promotion gate (no journey currently exercises the sixth-form filter, and the API contract is unchanged, so no e2e change is required — state this in the PR body).
- [ ] **Step 1: Mark spec §3 pipeline change as implemented**
In the spec, change the §3 heading `### Pipeline change (future work)` to `### Pipeline change (implemented 2026-07-07)` and append one line at the end of that subsection:
```markdown
Implemented in `feat/gias-sixth-form-flag` — see
`docs/superpowers/plans/2026-07-07-gias-sixth-form-flag.md`.
```
- [ ] **Step 2: Commit**
```bash
git add docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md
git commit -m "docs: mark sixth-form flag pipeline change implemented
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
- [ ] **Step 3: Push and open the PR (Gitea)**
Push the branch, then create the PR against `main` using the Gitea API via the git credential helper (token-header auth 401s on this Gitea; basic auth from `git credential fill` works):
```bash
git push -u origin feat/gias-sixth-form-flag
```
PR title: `feat: drive sixth-form separation from GIAS OfficialSixthForm flag`
PR body must note: (1) API contract unchanged (`has_sixth_form=yes|no`), (2) flag is NULL until the next pipeline run — backend and frontend degrade to "no sixth form" / age-range fallback, (3) no e2e journey change needed, and end with the standard generation footer.
- [ ] **Step 4: Verify CI passes**
Watch the PR checks (typecheck, tests, builds, AI review). All must pass before merge; merging deploys to staging automatically.
@@ -0,0 +1,799 @@
# GIAS Code Dictionaries Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Store the six GIAS classification fields as official DfE integer codes in the marts and translate code → name in application code, leaving the API contract (name strings) unchanged.
**Architecture:** A generation script downloads the public GIAS bulk CSV and emits the dictionaries (Python dicts + a dbt seed) from real data. The tap ingests the `(code)` columns, staging casts them, `dim_school`/`dim_location` keep only codes, and translation happens in exactly two places: `backend/data_loader.py` right after `pd.read_sql`, and `pipeline/scripts/sync_typesense.py` before indexing. A dbt seed test warns when DfE adds/renames a value; a parity test keeps the backend and pipeline dictionary copies identical.
**Tech Stack:** Singer SDK tap, dbt (Postgres), FastAPI + pandas, Typesense sync script, pytest.
**Spec:** `docs/superpowers/specs/2026-07-09-gias-code-dictionaries-design.md`
## Global Constraints
- **Numeric code values are never assumed.** Every literal code used in SQL or yml (status filter, sixth-form derivation, phase cascade) must be verified against `pipeline/transform/seeds/gias_code_names.csv` generated in Task 1 from the live CSV. The literals written in this plan are best-current-knowledge and each carries a verification step.
- **Names served by the API must stay byte-identical** to today's strings (e.g. `Does not apply`, `Open, but proposed to close`) — UI heuristics compare exact strings.
- The `(name)` columns stay declared in the tap and present in raw; staging stops exposing them.
- `dim_school` and `dim_location` status filters must stay identical (API inner-joins them).
- Backend tests run via: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -v` (no local pytest exists).
- dbt cannot run locally — dbt changes are verified statically (grep / yaml parse) + CI.
- Never push to `main`. Work on branch `feat/gias-code-dictionaries` (branch off `docs/gias-code-dictionaries` so the spec is included, or off `main` if that has merged).
- Commits end with: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
- Deploy runbook (accepted window, spec §7): merge → deploy → trigger `school_data_daily` immediately. No code-level fallback for the old-schema window.
---
### Task 1: Dictionary generation script, canonical module, pipeline copy, seed
**Files:**
- Create: `pipeline/scripts/generate_gias_codes.py`
- Create: `backend/gias_codes.py` (content generated by the script)
- Create: `pipeline/scripts/gias_codes.py` (byte-identical copy)
- Create: `pipeline/transform/seeds/gias_code_names.csv` (generated)
- Test: `backend/tests/test_gias_codes.py`
**Interfaces:**
- Produces: `backend/gias_codes.py` exporting `SCHOOL_TYPE`, `ESTABLISHMENT_STATUS`, `PHASE_OF_EDUCATION`, `OFFICIAL_SIXTH_FORM`, `RELIGIOUS_CHARACTER`, `ADMISSIONS_POLICY` (each `dict[int, str]`) and `translate(code, mapping) -> str | None`. Task 4 imports these; Task 5 imports the pipeline copy; Task 3 reads code literals from the seed CSV.
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_gias_codes.py`:
```python
"""Tests for the GIAS code->name dictionaries (spec 2026-07-09).
The dictionaries are generated from the live GIAS bulk CSV by
pipeline/scripts/generate_gias_codes.py — these tests assert the module's
contract, key sentinel values the marts/UI depend on, and that the pipeline
copy has not drifted from the canonical backend module.
"""
import math
from pathlib import Path
from backend.gias_codes import (
ADMISSIONS_POLICY,
ESTABLISHMENT_STATUS,
OFFICIAL_SIXTH_FORM,
PHASE_OF_EDUCATION,
RELIGIOUS_CHARACTER,
SCHOOL_TYPE,
translate,
)
REPO = Path(__file__).resolve().parents[2]
def test_translate_known_code():
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
assert translate(open_code, ESTABLISHMENT_STATUS) == "Open"
def test_translate_unknown_code_degrades_gracefully():
assert translate(9999, ESTABLISHMENT_STATUS) == "Unknown (9999)"
def test_translate_none_and_nan_return_none():
assert translate(None, ESTABLISHMENT_STATUS) is None
assert translate(float("nan"), ESTABLISHMENT_STATUS) is None
def test_translate_accepts_float_codes():
# pd.read_sql yields float columns when NULLs are present
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
assert translate(float(open_code), ESTABLISHMENT_STATUS) == "Open"
def test_sentinel_names_present():
"""Names the marts/UI compare against must exist verbatim."""
assert "Open" in ESTABLISHMENT_STATUS.values()
assert "Open, but proposed to close" in ESTABLISHMENT_STATUS.values()
assert "Has a sixth form" in OFFICIAL_SIXTH_FORM.values()
assert "Primary" in PHASE_OF_EDUCATION.values()
assert "Secondary" in PHASE_OF_EDUCATION.values()
assert "Does not apply" in RELIGIOUS_CHARACTER.values()
assert all(len(d) > 0 for d in (
SCHOOL_TYPE, ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION,
OFFICIAL_SIXTH_FORM, RELIGIOUS_CHARACTER, ADMISSIONS_POLICY,
))
def test_pipeline_copy_is_identical():
canonical = (REPO / "backend" / "gias_codes.py").read_text()
copy = (REPO / "pipeline" / "scripts" / "gias_codes.py").read_text()
assert canonical == copy, (
"pipeline/scripts/gias_codes.py has drifted from backend/gias_codes.py — "
"regenerate with pipeline/scripts/generate_gias_codes.py and copy the file"
)
def test_seed_matches_dictionaries():
import csv
fields = {
"school_type": SCHOOL_TYPE,
"establishment_status": ESTABLISHMENT_STATUS,
"phase_of_education": PHASE_OF_EDUCATION,
"official_sixth_form": OFFICIAL_SIXTH_FORM,
"religious_character": RELIGIOUS_CHARACTER,
"admissions_policy": ADMISSIONS_POLICY,
}
seed_path = REPO / "pipeline" / "transform" / "seeds" / "gias_code_names.csv"
seed: dict[str, dict[int, str]] = {k: {} for k in fields}
with open(seed_path, newline="") as fh:
for row in csv.DictReader(fh):
seed[row["field"]][int(row["code"])] = row["name"]
assert seed == fields
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_gias_codes.py -v`
Expected: FAIL at import — `ModuleNotFoundError: No module named 'backend.gias_codes'`.
- [ ] **Step 3: Write the generation script**
Create `pipeline/scripts/generate_gias_codes.py`:
```python
"""Generate GIAS code->name dictionaries from the live bulk CSV.
Writes:
- backend/gias_codes.py (canonical Python module)
- pipeline/scripts/gias_codes.py (byte-identical copy)
- pipeline/transform/seeds/gias_code_names.csv (dbt seed for drift test)
Run from the repo root whenever the dbt drift test warns that DfE
added/renamed a value: python pipeline/scripts/generate_gias_codes.py
"""
from __future__ import annotations
import io
import sys
from datetime import date, timedelta
from pathlib import Path
import pandas as pd
import requests
GIAS_URL = (
"https://ea-edubase-api-prod.azurewebsites.net"
"/edubase/downloads/public/edubasealldata{date}.csv"
)
# (CSV code column, CSV name column, python dict name, seed field key)
FIELDS = [
("TypeOfEstablishment (code)", "TypeOfEstablishment (name)", "SCHOOL_TYPE", "school_type"),
("EstablishmentStatus (code)", "EstablishmentStatus (name)", "ESTABLISHMENT_STATUS", "establishment_status"),
("PhaseOfEducation (code)", "PhaseOfEducation (name)", "PHASE_OF_EDUCATION", "phase_of_education"),
("OfficialSixthForm (code)", "OfficialSixthForm (name)", "OFFICIAL_SIXTH_FORM", "official_sixth_form"),
("ReligiousCharacter (code)", "ReligiousCharacter (name)", "RELIGIOUS_CHARACTER", "religious_character"),
("AdmissionsPolicy (code)", "AdmissionsPolicy (name)", "ADMISSIONS_POLICY", "admissions_policy"),
]
MODULE_HEADER = '''"""GIAS code -> name dictionaries.
GENERATED by pipeline/scripts/generate_gias_codes.py from the GIAS bulk CSV
— do not edit by hand; rerun the script when the dbt drift test warns.
The canonical file is backend/gias_codes.py; pipeline/scripts/gias_codes.py
must be byte-identical (enforced by backend/tests/test_gias_codes.py).
"""
from __future__ import annotations
import logging
import math
logger = logging.getLogger(__name__)
'''
MODULE_FOOTER = '''
def translate(code, mapping: dict[int, str]) -> str | None:
"""Translate a GIAS code to its display name.
None/NaN -> None (column absent or suppressed). Unknown codes degrade to
"Unknown (<code>)" with a warning so a new DfE value never blanks the UI.
"""
if code is None or (isinstance(code, float) and math.isnan(code)):
return None
code = int(code)
if code not in mapping:
logger.warning("Unknown GIAS code %s (not in dictionary)", code)
return f"Unknown ({code})"
return mapping[code]
'''
def download_csv() -> pd.DataFrame:
for day in (date.today(), date.today() - timedelta(days=1)):
url = GIAS_URL.format(date=day.strftime("%Y%m%d"))
print(f"Downloading {url}")
resp = requests.get(url, timeout=300)
if resp.status_code == 404:
continue
resp.raise_for_status()
return pd.read_csv(
io.StringIO(resp.content.decode("latin-1")),
dtype=str, keep_default_na=False,
)
sys.exit("GIAS CSV not available for today or yesterday")
def main() -> None:
repo = Path(__file__).resolve().parents[2]
df = download_csv()
module_parts = [MODULE_HEADER]
seed_rows: list[tuple[str, int, str]] = []
for code_col, name_col, dict_name, field_key in FIELDS:
pairs = (
df[[code_col, name_col]]
.loc[lambda d: (d[code_col] != "") & (d[name_col] != "")]
.drop_duplicates()
)
mapping = sorted((int(c), n) for c, n in pairs.itertuples(index=False))
dupes = len(mapping) - len({c for c, _ in mapping})
if dupes:
sys.exit(f"{code_col}: {dupes} codes map to multiple names — investigate before generating")
lines = [f"{dict_name}: dict[int, str] = {{"]
for code, name in mapping:
escaped = name.replace('"', '\\"')
lines.append(f' {code}: "{escaped}",')
lines.append("}\n")
module_parts.append("\n".join(lines))
seed_rows += [(field_key, code, name) for code, name in mapping]
module = "\n".join(module_parts) + MODULE_FOOTER
(repo / "backend" / "gias_codes.py").write_text(module)
(repo / "pipeline" / "scripts" / "gias_codes.py").write_text(module)
seed_path = repo / "pipeline" / "transform" / "seeds" / "gias_code_names.csv"
with open(seed_path, "w", newline="") as fh:
import csv
w = csv.writer(fh)
w.writerow(["field", "code", "name"])
w.writerows(seed_rows)
print(f"Wrote backend/gias_codes.py, pipeline/scripts/gias_codes.py, {seed_path.name}")
print("\nKey codes for the dbt work (Task 3):")
for field in ("establishment_status", "phase_of_education", "official_sixth_form"):
print(f" {field}:")
for f, code, name in seed_rows:
if f == field:
print(f" {code} = {name}")
if __name__ == "__main__":
main()
```
- [ ] **Step 4: Run the generator**
Run: `cd /Users/tudor/projects/school_compare && uv run --with pandas --with requests python pipeline/scripts/generate_gias_codes.py`
Expected: downloads the CSV (~100MB, may take a minute), writes the three files, and prints the status/phase/sixth-form code tables. **Record the printed code tables — Task 3 needs them.** If the download fails twice, report BLOCKED (no network or GIAS outage) rather than inventing dictionary content.
- [ ] **Step 5: Run the tests again**
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_gias_codes.py -v`
Expected: 7 passed. If `test_sentinel_names_present` fails, the GIAS vocabulary differs from expectations — inspect the generated module and report DONE_WITH_CONCERNS naming the differing value; do not edit the generated names.
- [ ] **Step 6: Commit**
```bash
git add pipeline/scripts/generate_gias_codes.py backend/gias_codes.py pipeline/scripts/gias_codes.py pipeline/transform/seeds/gias_code_names.csv backend/tests/test_gias_codes.py
git commit -m "feat: GIAS code->name dictionaries generated from live bulk CSV
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: Tap ingests the (code) columns; staging exposes codes, drops names
**Files:**
- Modify: `pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py` (Singer schema)
- Modify: `pipeline/transform/models/staging/stg_gias_establishments.sql`
**Interfaces:**
- Produces: staging columns `school_type_code`, `status_code`, `phase_code`, `official_sixth_form_code`, `religious_character_code`, `admissions_policy_code` (all int) consumed by Task 3. Staging **stops exposing** `school_type`, `status`, `phase`, `official_sixth_form`, `religious_character`, `admissions_policy` (names stay in raw only).
- [ ] **Step 1: Add the six (code) properties to the Singer schema**
In `tap.py`, `GIASEstablishmentsStream.schema`, add each `(code)` property directly above its existing `(name)` sibling:
```python
th.Property("TypeOfEstablishment (code)", th.StringType),
th.Property("PhaseOfEducation (code)", th.StringType),
th.Property("EstablishmentStatus (code)", th.StringType),
th.Property("Gender (name)", ...) # existing line — for placement reference only
th.Property("ReligiousCharacter (code)", th.StringType),
th.Property("AdmissionsPolicy (code)", th.StringType),
th.Property("OfficialSixthForm (code)", th.StringType),
```
(The exact insertion order doesn't matter — the schema is a dict — but keep each `(code)` adjacent to its `(name)` for readability. Do NOT remove any `(name)` property.)
- [ ] **Step 2: Rewrite the six columns in staging**
In `stg_gias_establishments.sql` `renamed` CTE, replace:
```sql
"TypeOfEstablishment (name)" as school_type,
"PhaseOfEducation (name)" as phase,
nullif(trim("OfficialSixthForm (name)"), '') as official_sixth_form,
"ReligiousCharacter (name)" as religious_character,
"AdmissionsPolicy (name)" as admissions_policy,
"EstablishmentStatus (name)" as status,
```
with:
```sql
cast(nullif(trim("TypeOfEstablishment (code)"), '') as integer) as school_type_code,
cast(nullif(trim("PhaseOfEducation (code)"), '') as integer) as phase_code,
cast(nullif(trim("OfficialSixthForm (code)"), '') as integer) as official_sixth_form_code,
cast(nullif(trim("ReligiousCharacter (code)"), '') as integer) as religious_character_code,
cast(nullif(trim("AdmissionsPolicy (code)"), '') as integer) as admissions_policy_code,
cast(nullif(trim("EstablishmentStatus (code)"), '') as integer) as status_code,
```
(The name lines are scattered through the CTE — replace each in place; the six name aliases must no longer appear in the model.)
- [ ] **Step 3: Verify statically**
Run:
```bash
cd /Users/tudor/projects/school_compare && \
python3 -c "import ast; ast.parse(open('pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py').read()); print('tap OK')" && \
grep -c "(code)" pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py && \
grep -E "as (school_type|status|phase|official_sixth_form|religious_character|admissions_policy)," pipeline/transform/models/staging/stg_gias_establishments.sql; echo "name-alias grep exit=$? (want 1 = none found)"
```
Expected: `tap OK`, code-column count `6`, and the final grep finds nothing (exit 1).
- [ ] **Step 4: Commit**
```bash
git add pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py pipeline/transform/models/staging/stg_gias_establishments.sql
git commit -m "feat(pipeline): ingest GIAS code columns; staging exposes codes not names
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: Marts store codes; dbt tests + drift test
**Files:**
- Modify: `pipeline/transform/models/marts/dim_school.sql`
- Modify: `pipeline/transform/models/marts/dim_location.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml`
- Create: `pipeline/transform/tests/assert_gias_code_names_match_seed.sql`
**Interfaces:**
- Consumes: staging code columns from Task 2; code literals from `pipeline/transform/seeds/gias_code_names.csv` (Task 1).
- Produces: `dim_school` columns `school_type_code`, `status_code`, `phase_code`, `religious_character_code`, `admissions_policy_code` (int) replacing their string columns; `has_sixth_form` unchanged (bool). Task 4's `_MAIN_QUERY` selects these.
**Before writing SQL: open `pipeline/transform/seeds/gias_code_names.csv` and confirm the literals below.** Best-current-knowledge values (VERIFY EACH):
`establishment_status`: 1 = Open, 3 = "Open, but proposed to close" (2 = Closed, 4 = Proposed to open).
`phase_of_education`: 0 = Not applicable, 2 = Primary, 4 = Secondary, 7 = All-through.
`official_sixth_form`: 1 = Has a sixth form, 2 = Does not have a sixth form, 0 = Not applicable.
If any differ, use the seed's values everywhere below and say so in your report.
- [ ] **Step 1: Rewrite dim_school.sql derivations in code space**
Replace the phase cascade block (`case ... end as phase,`) with:
```sql
-- Phase in GIAS code space (see seeds/gias_code_names.csv):
-- 2 = Primary, 4 = Secondary, 7 = All-through, 0 = Not applicable.
case
-- 1. Trust GIAS phase when it's a real value (0 = the catch-all "Not Applicable")
when s.phase_code is not null and s.phase_code != 0
then s.phase_code
-- 2. Infer from statutory age range (independent schools still publish these)
when s.statutory_high_age is not null and s.statutory_high_age <= 11 then 2
when s.statutory_low_age is not null and s.statutory_low_age >= 11 then 4
when s.statutory_low_age is not null and s.statutory_high_age is not null
and s.statutory_low_age < 11 and s.statutory_high_age > 11 then 7
-- 3. Fallback: infer from school name (covers independents with missing ages)
when s.school_name ilike '%primary%'
or s.school_name ilike '%infant%'
or s.school_name ilike '%junior%'
or s.school_name ilike '%preparatory%'
or s.school_name ilike '% prep school%'
or s.school_name ilike '% prep %'
then 2
when s.school_name ilike '%secondary%'
or s.school_name ilike '%high school%'
or s.school_name ilike '%grammar%'
or s.school_name ilike '%senior school%'
or s.school_name ilike '%upper school%'
then 4
-- 4. Give up — null renders no phase pill
else null
end as phase_code,
```
Replace `s.school_type,` with `s.school_type_code,`; `s.religious_character,` with `s.religious_character_code,`; `s.admissions_policy,` with `s.admissions_policy_code,`; `s.status,` with `s.status_code,`.
Replace the has_sixth_form case with:
```sql
-- GIAS OfficialSixthForm in code space: 1 = has, 2 = does not, 0 = N/A.
-- Null (rare, new establishments) falls back to the statutory age range.
case
when s.official_sixth_form_code = 1 then true
when s.official_sixth_form_code in (0, 2) then false
else coalesce(s.statutory_high_age >= 18, false)
end as has_sixth_form,
```
Replace the status filter with:
```sql
-- 1 = Open; 3 = Open, but proposed to close (still operating; drops out when
-- GIAS flips to Closed — marts fully rebuild each run).
where s.status_code in (1, 3)
```
- [ ] **Step 2: Same filter in dim_location.sql**
Replace its `where s.status in ('Open', 'Open, but proposed to close')` (and the comment above it) with:
```sql
-- Must match dim_school's status filter exactly (the API inner-joins the two).
where s.status_code in (1, 3)
```
- [ ] **Step 3: Update _marts_schema.yml**
Under `dim_school` columns: rename `phase``phase_code` (keep the warn-severity not_null, reword description to mention codes); replace the `status` accepted_values block with:
```yaml
- name: status_code
description: GIAS EstablishmentStatus code (1 = Open, 3 = Open but proposed to close)
tests:
- accepted_values:
values: [1, 3]
```
Add warn-severity accepted_values for the other codes, values copied from the seed (school_type/religious/admissions lists are long — paste the full code list from `gias_code_names.csv` for each):
```yaml
- name: school_type_code
tests:
- accepted_values:
severity: warn
values: [<all school_type codes from the seed>]
- name: religious_character_code
tests:
- accepted_values:
severity: warn
values: [<all religious_character codes from the seed>]
- name: admissions_policy_code
tests:
- accepted_values:
severity: warn
values: [<all admissions_policy codes from the seed>]
```
(`<...>` here means: paste the actual comma-separated integers from the seed file — the lists exist by the time this task runs. Leaving a literal `<...>` in the yml is a task failure.)
`has_sixth_form` tests stay unchanged.
- [ ] **Step 4: Write the drift test**
Create `pipeline/transform/tests/assert_gias_code_names_match_seed.sql`:
```sql
-- Warn when the live GIAS CSV carries a (code, name) pair we don't have in
-- the dictionary seed — i.e. DfE added or renamed a value. Fix by rerunning
-- pipeline/scripts/generate_gias_codes.py and committing the regenerated
-- dictionaries + seed together.
{{ config(severity='warn') }}
with raw_pairs as (
{% for field_key, code_col, name_col in [
('school_type', 'TypeOfEstablishment (code)', 'TypeOfEstablishment (name)'),
('establishment_status', 'EstablishmentStatus (code)', 'EstablishmentStatus (name)'),
('phase_of_education', 'PhaseOfEducation (code)', 'PhaseOfEducation (name)'),
('official_sixth_form', 'OfficialSixthForm (code)', 'OfficialSixthForm (name)'),
('religious_character', 'ReligiousCharacter (code)', 'ReligiousCharacter (name)'),
('admissions_policy', 'AdmissionsPolicy (code)', 'AdmissionsPolicy (name)')
] %}
select distinct
'{{ field_key }}' as field,
cast(nullif(trim("{{ code_col }}"), '') as integer) as code,
nullif(trim("{{ name_col }}"), '') as name
from {{ source('raw', 'gias_establishments') }}
where nullif(trim("{{ code_col }}"), '') is not null
and nullif(trim("{{ name_col }}"), '') is not null
{% if not loop.last %}union all{% endif %}
{% endfor %}
)
select r.*
from raw_pairs r
left join {{ ref('gias_code_names') }} s
on s.field = r.field
and s.code = r.code
and s.name = r.name
where s.field is null
```
- [ ] **Step 5: Verify statically**
Run:
```bash
cd /Users/tudor/projects/school_compare && \
uv run --with pyyaml python -c "import yaml; yaml.safe_load(open('pipeline/transform/models/marts/_marts_schema.yml')); print('yml OK')" && \
grep -c "_code" pipeline/transform/models/marts/dim_school.sql && \
grep -n "status_code in (1, 3)" pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/dim_location.sql && \
grep -rn "s\.status\b\|s\.phase\b\|s\.school_type\b\|s\.religious_character\b\|s\.admissions_policy\b\|official_sixth_form\b" pipeline/transform/models/marts/dim_school.sql | grep -v "_code"; echo "stale-name grep exit=$? (want 1)"
```
Expected: `yml OK`, both filters matched, and no stale name-column references (final grep exits 1).
- [ ] **Step 6: Commit**
```bash
git add pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/dim_location.sql pipeline/transform/models/marts/_marts_schema.yml pipeline/transform/tests/assert_gias_code_names_match_seed.sql
git commit -m "feat(pipeline): dim_school/dim_location store GIAS codes; seed drift test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Backend translates at the API boundary
**Files:**
- Modify: `backend/models.py` (DimSchool columns)
- Modify: `backend/data_loader.py` (`_MAIN_QUERY` + translation)
- Test: `backend/tests/test_gias_translation.py` (new)
**Interfaces:**
- Consumes: `backend/gias_codes.py` dictionaries + `translate` (Task 1); mart code columns (Task 3).
- Produces: `translate_gias_code_columns(df) -> df` in `backend/data_loader.py`; after `load_school_data_as_dataframe()` the DataFrame carries today's name columns (`phase`, `school_type`, `status`, `religious_denomination`, `admissions_policy`) — every downstream consumer unchanged.
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_gias_translation.py`:
```python
"""API-boundary translation: marts now carry GIAS codes; the DataFrame the
rest of the backend sees must carry today's name strings."""
import numpy as np
import pandas as pd
from backend.data_loader import translate_gias_code_columns
from backend.gias_codes import ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION
def _code_for(mapping, name):
return next(c for c, n in mapping.items() if n == name)
def test_codes_become_todays_names():
df = pd.DataFrame([{
"urn": 1,
"phase_code": float(_code_for(PHASE_OF_EDUCATION, "Primary")),
"school_type_code": np.nan,
"status_code": float(_code_for(ESTABLISHMENT_STATUS, "Open, but proposed to close")),
"religious_character_code": np.nan,
"admissions_policy_code": np.nan,
}])
out = translate_gias_code_columns(df)
row = out.iloc[0]
assert row["phase"] == "Primary"
assert row["status"] == "Open, but proposed to close"
assert row["school_type"] is None
assert row["religious_denomination"] is None
assert row["admissions_policy"] is None
def test_unknown_code_degrades_not_blanks():
df = pd.DataFrame([{"urn": 1, "phase_code": 9999.0}])
out = translate_gias_code_columns(df)
assert out.iloc[0]["phase"] == "Unknown (9999)"
def test_missing_code_columns_are_a_noop():
"""Old-schema DataFrames (tests, pre-pipeline DBs) pass through untouched."""
df = pd.DataFrame([{"urn": 1, "phase": "Primary", "status": "Open"}])
out = translate_gias_code_columns(df)
assert out.iloc[0]["phase"] == "Primary"
assert out.iloc[0]["status"] == "Open"
```
- [ ] **Step 2: Run to verify failure**
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_gias_translation.py -v`
Expected: FAIL — `ImportError: cannot import name 'translate_gias_code_columns'`.
- [ ] **Step 3: Implement translation in data_loader.py**
Add near the top of `backend/data_loader.py` (after existing imports):
```python
from .gias_codes import (
ADMISSIONS_POLICY,
ESTABLISHMENT_STATUS,
PHASE_OF_EDUCATION,
RELIGIOUS_CHARACTER,
SCHOOL_TYPE,
translate,
)
# mart code column -> (API name column, dictionary)
_GIAS_CODE_COLUMNS = {
"phase_code": ("phase", PHASE_OF_EDUCATION),
"school_type_code": ("school_type", SCHOOL_TYPE),
"status_code": ("status", ESTABLISHMENT_STATUS),
"religious_character_code": ("religious_denomination", RELIGIOUS_CHARACTER),
"admissions_policy_code": ("admissions_policy", ADMISSIONS_POLICY),
}
def translate_gias_code_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Map GIAS code columns to today's name columns (API contract).
Runs immediately after pd.read_sql so every downstream consumer —
filters, PHASE_GROUPS, payloads, /api/filters — keeps seeing names.
DataFrames without the code columns (old schema, test fixtures) pass
through unchanged.
"""
for code_col, (name_col, mapping) in _GIAS_CODE_COLUMNS.items():
if code_col in df.columns:
df[name_col] = df[code_col].map(lambda c: translate(c, mapping))
return df
```
- [ ] **Step 4: Switch `_MAIN_QUERY` to code columns and call the translation**
In `_MAIN_QUERY` replace:
`s.phase,``s.phase_code,` · `s.school_type,``s.school_type_code,` · `s.religious_character AS religious_denomination,``s.religious_character_code,` · `s.admissions_policy,``s.admissions_policy_code,` · `s.status,``s.status_code,`
In `load_school_data_as_dataframe()`, insert the call immediately after the empty-check and **before** the existing `normalize_school_type` line:
```python
if df.empty:
return df
df = translate_gias_code_columns(df)
# Build address string
...
# Normalize school type (existing line — now normalises the translated name)
df["school_type"] = df["school_type"].apply(normalize_school_type)
```
- [ ] **Step 5: Update DimSchool in models.py**
Replace `phase = Column(String(100))`, `school_type = Column(String(100))`, `religious_character = Column(String(100))`, `admissions_policy = Column(String(50))`, `status = Column(String(50))` with:
```python
phase_code = Column(Integer)
school_type_code = Column(Integer)
religious_character_code = Column(Integer)
admissions_policy_code = Column(Integer)
status_code = Column(Integer)
```
Then check nothing else references the removed attributes:
```bash
grep -rn "\.phase\b\|\.school_type\b\|\.religious_character\b\|\.admissions_policy\b\|\.status\b" backend/*.py | grep -i "dimschool\|DimSchool"
```
Expected: no hits (the backend reads via `_MAIN_QUERY`, not ORM attributes). If there are hits, update them to the `_code` columns + translation and note it in your report.
- [ ] **Step 6: Run the new tests and the whole backend suite**
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -v`
Expected: all pass — 3 new + all pre-existing (their fixtures carry name columns; translation is a no-op on them).
- [ ] **Step 7: Commit**
```bash
git add backend/models.py backend/data_loader.py backend/tests/test_gias_translation.py
git commit -m "feat(api): translate GIAS codes to names at the query boundary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 5: Typesense sync translates before indexing
**Files:**
- Modify: `pipeline/scripts/sync_typesense.py`
**Interfaces:**
- Consumes: `pipeline/scripts/gias_codes.py` (Task 1), mart code columns (Task 3).
- Produces: identical Typesense documents to today (facet values are names).
- [ ] **Step 1: Switch the SELECT and translate**
In `sync_typesense.py`: add at the top (the DAG runs `python scripts/sync_typesense.py`, so `scripts/` is `sys.path[0]` and a plain import works):
```python
from gias_codes import PHASE_OF_EDUCATION, RELIGIOUS_CHARACTER, SCHOOL_TYPE, translate
```
In the SQL, replace `s.phase,``s.phase_code,`, `s.school_type,``s.school_type_code,`, `s.religious_character,``s.religious_character_code,`.
In the document builder, replace:
```python
"phase": row["phase"] or "",
"school_type": row["school_type"] or "",
```
with:
```python
"phase": translate(row["phase_code"], PHASE_OF_EDUCATION) or "",
"school_type": translate(row["school_type_code"], SCHOOL_TYPE) or "",
```
and:
```python
if row.get("religious_character"):
doc["religious_character"] = row["religious_character"]
```
with:
```python
religious_character = translate(row.get("religious_character_code"), RELIGIOUS_CHARACTER)
if religious_character:
doc["religious_character"] = religious_character
```
- [ ] **Step 2: Verify statically**
Run:
```bash
cd /Users/tudor/projects/school_compare && \
python3 -c "import ast; ast.parse(open('pipeline/scripts/sync_typesense.py').read()); print('sync OK')" && \
grep -n "row\[\"phase\"\]\|row\[\"school_type\"\]\|row\[\"religious_character\"\]" pipeline/scripts/sync_typesense.py; echo "stale grep exit=$? (want 1)"
```
Expected: `sync OK`, no stale name-column row accesses.
- [ ] **Step 3: Commit**
```bash
git add pipeline/scripts/sync_typesense.py
git commit -m "feat(pipeline): typesense sync translates GIAS codes before indexing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 6: Spec status, PR, deploy runbook
**Files:**
- Modify: `docs/superpowers/specs/2026-07-09-gias-code-dictionaries-design.md` (status line)
- [ ] **Step 1: Mark the spec implemented**
Change `**Status:** Approved design` to `**Status:** Implemented 2026-07-09 — see docs/superpowers/plans/2026-07-09-gias-code-dictionaries.md`.
- [ ] **Step 2: Commit and push**
```bash
git add docs/superpowers/specs/2026-07-09-gias-code-dictionaries-design.md
git commit -m "docs: mark GIAS code dictionaries spec implemented
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
git push -u origin feat/gias-code-dictionaries
```
- [ ] **Step 3: Open the PR (Gitea API via git credential fill — token-header auth 401s)**
Title: `feat: GIAS classification fields stored as codes, translated in code`
Body must include: (1) API contract unchanged — names still served, translation at the query boundary; (2) the **deploy runbook: merge → deploy → trigger `school_data_daily` immediately** (accepted empty-API window until the marts rebuild — spec §7); (3) dictionary maintenance loop (dbt drift test warns → rerun `generate_gias_codes.py` → commit regenerated files); (4) no frontend/e2e changes. End with the standard generation footer.
- [ ] **Step 4: Watch CI**
All PR checks must pass. Do not merge — merging triggers the deploy window; the human runs the runbook.
@@ -0,0 +1,591 @@
# Compare-Screen Data Foundation (Pipeline PR) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Land every pipeline/dbt change the compare-screen redesign needs (spec §5 + §8 of `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md`): promote raw-but-unstored fields to marts, close the national-averages gaps, and wire the Ofsted report-card columns.
**Architecture:** Meltano Singer taps load `raw.*` tables; dbt builds `staging``marts` (read-only for the backend). All changes here are additive columns/rows — no breaking changes to existing marts. The full `dbt build` runs on the server via the Airflow DAGs; locally we gate with `dbt parse` (no DB needed) plus network-only diagnostic scripts.
**Tech Stack:** Python (Singer SDK taps), dbt-postgres ~1.10 (invoked as `python -m dbt.cli.main`), Meltano, PostgreSQL.
## Global Constraints
- **No new external sources** (spec §5): only fields already in the `raw` schema or in files the taps already download. The one sanctioned tap change is the Ofsted MI report-card columns (spec §5, §8.4) and the legacy-KS2 year addition (same DfE performance-tables source).
- **Additive only:** never rename or drop existing mart columns; the backend maps them 1:1 in `backend/models.py`.
- **Never push to `main`.** Branch: `feat/compare-data-foundation`; PR checks must pass.
- Backend `models.py` changes belong to the follow-up backend PR, not this one.
- dbt invocation is always `python -m dbt.cli.main` (a bare `dbt` resolves to the wrong binary — see `pipeline/dags/school_data_pipeline.py:27`).
- EES suppression codes `z`/`c`/`x` must go through the `safe_numeric` macro.
- Computed benchmarks (FSM/EAL/SEN medians, disadvantaged national average) are **backend work** (spec §5) — explicitly out of scope here.
---
### Task 0: Create the branch
**Files:** none
- [ ] **Step 1:** `git checkout main && git pull && git checkout -b feat/compare-data-foundation`
---
### Task 1: Diagnostics — pin the three unknowns
The spec flags three facts we must confirm from the actual files before wiring code: (a) why `gps_expected_pct`/`science_expected_pct` are NULL in `marts.fact_ks2_national_averages` despite being mapped end-to-end; (b) what the KS2 attainment long file calls its subjects/years for 2021/22 and 2022/23 (subject-level 2022/23 is NULL in prod; school-level 2021/22 is absent); (c) the exact report-card column headers in the current Ofsted MI CSV.
**Files:**
- Create: `pipeline/scripts/diagnose_compare_gaps.py`
**Interfaces:**
- Produces: a printed findings report; Tasks 5, 6, 7 consume the confirmed column/label names. Precedent: `pipeline/scripts/diagnose_ees_ks4.py`.
- [ ] **Step 1: Write the diagnostic script**
```python
"""Diagnose the three data gaps blocking the compare-screen redesign.
Run from repo root (network access required, no DB needed):
python pipeline/scripts/diagnose_compare_gaps.py
"""
import io
import re
import sys
import zipfile
import pandas as pd
import requests
sys.path.insert(0, "pipeline/plugins/extractors/tap-uk-ees")
sys.path.insert(0, "pipeline/plugins/extractors/tap-uk-ofsted")
from tap_uk_ees.tap import ( # noqa: E402
_KS2_NATIONAL_COL_MAP,
_KS2_NATIONAL_CSV_URL,
download_release_zip,
get_all_releases,
)
from tap_uk_ofsted.tap import discover_csv_url # noqa: E402
TIMEOUT = 120
def check_national_gps_science():
print("\n=== (a) National catalogue CSV: GPS/science columns ===")
resp = requests.get(_KS2_NATIONAL_CSV_URL, timeout=TIMEOUT)
resp.raise_for_status()
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
df.columns = [c.strip().lower() for c in df.columns]
for csv_col in ("pt_gps_exp", "pt_scita_exp", "avg_readscore", "avg_matscore", "avg_gpsscore"):
status = "PRESENT" if csv_col in df.columns else "MISSING"
print(f" {csv_col}: {status}")
gps_like = [c for c in df.columns if "gps" in c or "scita" in c or "sci" in c]
print(f" all gps/science-ish columns: {gps_like}")
nat = df[df.get("geographic_level", "").str.strip().str.lower() == "national"]
print(f" national rows time_periods: {sorted(nat['time_period'].unique())}")
# Sample the values our map would read for the latest year
latest = nat[nat["time_period"] == nat["time_period"].max()]
for csv_col, field in _KS2_NATIONAL_COL_MAP.items():
val = latest.iloc[0].get(csv_col, "<col missing>") if len(latest) else "<no row>"
print(f" {field} <- {csv_col} = {val!r}")
def check_ks2_attainment_years_subjects():
print("\n=== (b) EES KS2 attainment: years & subject labels ===")
releases = get_all_releases("key-stage-2-attainment")
print(f" releases found: {[r['time_period'] for r in releases]}")
for release in releases:
zf = download_release_zip(release["id"])
name = next((n for n in zf.namelist()
if "ks2_school_attainment_data" in n and n.endswith(".csv")), None)
if not name:
print(f" {release['time_period']}: NO school attainment CSV in ZIP")
continue
with zf.open(name) as f:
df = pd.read_csv(f, dtype=str, keep_default_na=False, nrows=200000)
years = sorted(df["time_period"].unique())
subjects = sorted(df["subject"].unique())
print(f" release {release['time_period']}: time_periods={years}")
print(f" subjects={subjects}")
def check_ofsted_report_card_columns():
print("\n=== (c) Ofsted MI CSV: report-card columns ===")
url = discover_csv_url()
print(f" MI file: {url}")
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False, nrows=5)
rc_like = [c for c in df.columns
if re.search(r"report card|inclusion|curriculum|achievement|safeguard|well.?being|governance", c, re.I)]
print(f" candidate report-card columns ({len(rc_like)}):")
for c in rc_like:
print(f" - {c!r}")
if __name__ == "__main__":
check_national_gps_science()
check_ks2_attainment_years_subjects()
check_ofsted_report_card_columns()
```
Note: if `_KS2_NATIONAL_CSV_URL` is named differently in `tap_uk_ees/tap.py` (it is defined near the `_KS2_NATIONAL_COL_MAP` around line ~490), import whatever constant holds the catalogue CSV URL.
- [ ] **Step 2: Run it and record findings**
Run: `python pipeline/scripts/diagnose_compare_gaps.py 2>&1 | tee /tmp/compare-gaps-findings.txt`
Expected: three sections printed. Paste the findings as a comment block at the bottom of the script (so they're committed evidence), e.g. `# FINDINGS 2026-07-12: pt_gps_exp MISSING (actual col: ...), 202122 present in release X, rc columns: [...]`.
- [ ] **Step 3: Commit**
```bash
git add pipeline/scripts/diagnose_compare_gaps.py
git commit -m "chore(pipeline): diagnostic for compare-screen data gaps"
```
---
### Task 2: Admissions preference detail → mart
Staging already extracts `second_preference_offers`, `third_preference_offers`, `total_offers` (`stg_ees_admissions.sql:26-29`) — the mart drops them. The cross-LA fields are declared in the tap (`all_applications_from_another_LA`, `offers_to_applicants_from_another_LA`) but not selected in staging.
**Files:**
- Modify: `pipeline/transform/models/staging/stg_ees_admissions.sql` (after line 33, in `renamed`)
- Modify: `pipeline/transform/models/marts/fact_admissions.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_admissions block, ~line 120)
**Interfaces:**
- Produces mart columns: `total_offers int`, `second_preference_offers int`, `third_preference_offers int`, `cross_la_applications int`, `cross_la_offers int`. The backend PR will map these in `FactAdmissions`.
- [ ] **Step 1: Add cross-LA columns to staging**
In `stg_ees_admissions.sql`, after the `first_preference_applications` line (line 33):
```sql
-- Cross-borough demand: applications naming this school from families
-- living in another local authority, and offers made to them.
{{ safe_numeric('"all_applications_from_another_LA"') }}::integer as cross_la_applications,
{{ safe_numeric('"offers_to_applicants_from_another_LA"') }}::integer as cross_la_offers,
```
(Quote the identifiers — the tap emits them with mixed case, same trap as `FSM_eligible_percent`, see the header comment in that file. If `dbt parse` or the DAG run later shows the raw columns are lower-cased in Postgres, drop the double quotes.)
- [ ] **Step 2: Pass everything through the mart**
Replace the full select list in `fact_admissions.sql`:
```sql
-- Mart: School admissions — one row per URN per year
select
urn,
year,
school_phase,
places_offered,
total_offers,
total_applications,
first_preference_applications,
first_preference_offers,
second_preference_offers,
third_preference_offers,
cross_la_applications,
cross_la_offers,
first_preference_offer_pct,
oversubscription_ratio,
oversubscribed,
admissions_policy
from {{ ref('stg_ees_admissions') }}
```
- [ ] **Step 3: Add schema tests**
In `_marts_schema.yml` under `fact_admissions.columns`, append:
```yaml
- name: second_preference_offers
- name: third_preference_offers
- name: cross_la_applications
- name: cross_la_offers
- name: total_offers
```
- [ ] **Step 4: Parse gate**
Run: `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .`
Expected: `Done.` with no compilation errors.
- [ ] **Step 5: Commit**
```bash
git add pipeline/transform/models/staging/stg_ees_admissions.sql pipeline/transform/models/marts/fact_admissions.sql pipeline/transform/models/marts/_marts_schema.yml
git commit -m "feat(pipeline): admissions preference breakdown and cross-LA demand in marts"
```
---
### Task 3: KS2 progress confidence intervals + writing working-towards
The tap already emits `progress_measure_lower_conf_interval`, `progress_measure_upper_conf_interval`, `working_towards_expected_standard_pupil_percent` (tap.py:203-206). The staging pivot drops them. These power the CI-based Above/Average/Below progress chips (spec §8, first-review item on statistical honesty).
**Files:**
- Modify: `pipeline/transform/models/staging/stg_ees_ks2.sql` (inside the `pivoted` CTE, next to each subject's `progress_measure_score` case, lines ~41/55/72, and in the final select ~lines 145-152)
- Modify: `pipeline/transform/models/marts/fact_ks2_performance.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_ks2_performance block, ~line 82)
**Interfaces:**
- Produces mart columns: `reading_progress_lower_ci`, `reading_progress_upper_ci`, `writing_progress_lower_ci`, `writing_progress_upper_ci`, `maths_progress_lower_ci`, `maths_progress_upper_ci` (float), `writing_working_towards_pct` (float).
- [ ] **Step 1: Add pivot cases in staging**
After the `reading_progress` case (line ~41), add:
```sql
max(case when subject = 'Reading'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_lower_conf_interval') }} end) as reading_progress_lower_ci,
max(case when subject = 'Reading'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_upper_conf_interval') }} end) as reading_progress_upper_ci,
```
After the `writing_progress` case (line ~55), add:
```sql
max(case when subject = 'Writing'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_lower_conf_interval') }} end) as writing_progress_lower_ci,
max(case when subject = 'Writing'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_upper_conf_interval') }} end) as writing_progress_upper_ci,
max(case when subject = 'Writing'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('working_towards_expected_standard_pupil_percent') }} end) as writing_working_towards_pct,
```
After the `maths_progress` case (line ~72), add:
```sql
max(case when subject = 'Maths'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_lower_conf_interval') }} end) as maths_progress_lower_ci,
max(case when subject = 'Maths'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_upper_conf_interval') }} end) as maths_progress_upper_ci,
```
Then add the seven new columns to the model's final select (next to the existing `p.reading_progress` / `p.writing_progress` / `p.maths_progress` lines ~145-152):
```sql
p.reading_progress_lower_ci,
p.reading_progress_upper_ci,
p.writing_progress_lower_ci,
p.writing_progress_upper_ci,
p.writing_working_towards_pct,
p.maths_progress_lower_ci,
p.maths_progress_upper_ci,
```
- [ ] **Step 2: Pass through the mart**
In `fact_ks2_performance.sql`, add the same seven column names to the select list immediately after the existing `maths_progress` line (this mart selects staging columns by name; match the file's existing alias style — if columns are selected bare, add them bare).
- [ ] **Step 3: Schema tests**
In `_marts_schema.yml` under `fact_ks2_performance.columns`, append the seven names (no tests beyond presence — values are legitimately NULL for 2023/24+ since progress measures ended with 2022/23, spec §4.3):
```yaml
- name: reading_progress_lower_ci
- name: reading_progress_upper_ci
- name: writing_progress_lower_ci
- name: writing_progress_upper_ci
- name: writing_working_towards_pct
- name: maths_progress_lower_ci
- name: maths_progress_upper_ci
```
- [ ] **Step 4: Parse gate**
Run: `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .`
Expected: `Done.`
- [ ] **Step 5: Commit**
```bash
git add pipeline/transform/models/staging/stg_ees_ks2.sql pipeline/transform/models/marts/fact_ks2_performance.sql pipeline/transform/models/marts/_marts_schema.yml
git commit -m "feat(pipeline): KS2 progress confidence intervals and writing working-towards"
```
---
### Task 4: KS4 — Progress 8 banding and disadvantage gaps
The tap's `ees_ks4_info` stream already declares `progress8_banding` (DfE's own "well above average … well below average" label — the ready-made secondary chip), `attainment8_diffn` and `progress8_diffn` (tap.py:338-340). Wire them through staging into the mart.
**Files:**
- Modify: `pipeline/transform/models/staging/stg_ees_ks4.sql` (the CTE that reads `ees_ks4_info` — the same one that already surfaces `sen_pct`; add three columns to its select and to the final joined select)
- Modify: `pipeline/transform/models/marts/fact_ks4_performance.sql` (add after `progress_8_upper_ci`)
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_ks4_performance block, ~line 93)
**Interfaces:**
- Produces mart columns: `progress_8_banding text`, `attainment_8_disadvantage_gap float`, `progress_8_disadvantage_gap float`.
- [ ] **Step 1: Staging — select from the info source**
In the info CTE of `stg_ees_ks4.sql` add:
```sql
nullif(trim(progress8_banding), '') as progress_8_banding,
{{ safe_numeric('attainment8_diffn') }} as attainment_8_disadvantage_gap,
{{ safe_numeric('progress8_diffn') }} as progress_8_disadvantage_gap,
```
and add the three names to the model's final select (aliased the same way the CTE's other columns are).
- [ ] **Step 2: Mart passthrough**
In `fact_ks4_performance.sql`, after the `progress_8_upper_ci,` line:
```sql
progress_8_banding,
attainment_8_disadvantage_gap,
progress_8_disadvantage_gap,
```
- [ ] **Step 3: Schema tests** — append the three names under `fact_ks4_performance.columns`, plus an accepted-values guard that tolerates NULL:
```yaml
- name: progress_8_banding
tests:
- accepted_values:
values: ['Well above average', 'Above average', 'Average', 'Below average', 'Well below average']
config:
where: "progress_8_banding is not null"
- name: attainment_8_disadvantage_gap
- name: progress_8_disadvantage_gap
```
(If the DAG run later shows different capitalisation in the data, fix the accepted values to match the data, not vice versa.)
- [ ] **Step 4: Parse gate**`cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .``Done.`
- [ ] **Step 5: Commit**
```bash
git add pipeline/transform/models/staging/stg_ees_ks4.sql pipeline/transform/models/marts/fact_ks4_performance.sql pipeline/transform/models/marts/_marts_schema.yml
git commit -m "feat(pipeline): Progress 8 banding and KS4 disadvantage gaps in marts"
```
---
### Task 5: National averages — 2015/16 row and GPS/science/scaled-score fix
Two changes. (1) `stg_ees_ks2_national.sql:34` filters `>= 201617`, which is exactly why the England line starts a year late (2015/16 RWM = 53% exists in the catalogue). (2) GPS/science expected are NULL in prod despite full end-to-end mapping — Task 1's findings say whether the catalogue CSV column names differ from `_KS2_NATIONAL_COL_MAP` (`pt_gps_exp`, `pt_scita_exp`) or whether values are suppressed at source.
**Files:**
- Modify: `pipeline/transform/models/staging/stg_ees_ks2_national.sql:34`
- Modify (conditional on Task 1 findings): `pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py` (`_KS2_NATIONAL_COL_MAP`)
**Interfaces:**
- Produces: a 201516 row in `marts.fact_ks2_national_averages`; non-NULL `gps_expected_pct`, `science_expected_pct`, `reading_avg_score`, `maths_avg_score`, `gps_avg_score` for years the DfE publishes them. Backend/frontend consume via `/api/national-averages` unchanged (additive year + newly non-NULL fields).
- [ ] **Step 1: Widen the year filter**
In `stg_ees_ks2_national.sql`, change line 34:
```sql
and cast(trim(time_period) as integer) >= 201516
```
(2015/16 was the first year of the current expected-standard tests; nothing earlier is comparable, so keep a floor.)
- [ ] **Step 2: Fix the column map per Task 1 findings**
If Task 1 reported the actual CSV column names for GPS/science/scaled scores differ, update `_KS2_NATIONAL_COL_MAP` in `tap.py` accordingly, e.g. (illustrative — use the diagnosed names):
```python
_KS2_NATIONAL_COL_MAP = {
# ... existing entries ...
"pt_gps_exp": "gps_expected_pct", # replace key with diagnosed name
"pt_scita_exp": "science_expected_pct", # replace key with diagnosed name
}
```
If Task 1 showed the columns are present but suppressed (`x`) at national level for all years, instead delete the two entries from the map, delete the corresponding lines from `stg_ees_ks2_national.sql` and `fact_ks2_national_averages.sql`, and record in the PR description that GPS/science England ticks stay "not in dataset" (the mockups already carry that caveat).
- [ ] **Step 3: Parse gate**`cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .``Done.`
- [ ] **Step 4: Commit**
```bash
git add pipeline/transform/models/staging/stg_ees_ks2_national.sql pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py
git commit -m "fix(pipeline): include 2015/16 national averages; fix GPS/science national mapping"
```
---
### Task 6: Legacy KS2 — load the 2021/22 school-level year
School-level 2021/22 exists in DfE performance-tables archives (same source as the four legacy years already loaded) but in neither our legacy config (stops at 201819, `pipeline/meltano.yml:33-37`) nor EES (starts 2022/23) — unless Task 1's finding (b) showed an EES release carrying 202122, in which case skip this task and note why in the PR.
The legacy URLs point at the self-hosted filebrowser (`10.0.1.224:8081`) — **the 2021/22 DfE archive must be uploaded there first; this is the one human dependency in this plan.**
**Files:**
- Modify: `pipeline/meltano.yml` (legacy_ks2_urls block, line ~33)
**Interfaces:**
- Produces: `raw.legacy_ks2` rows with `year = '202122'`, flowing through `stg_legacy_ks2``fact_ks2_performance` unchanged (the stream maps old column names already; 2021/22 CSVs use the same `PTRWM_EXP`-style headers as 2018/19).
- [ ] **Step 1: Verify the 2021/22 CSV headers match `_LEGACY_KS2_COLUMN_MAP`**
Download the DfE 2021/22 KS2 revised archive (gov.uk "Compare School Performance data download": 2021-2022 all-schools ZIP), then:
Run: `python -c "import zipfile,io,pandas as pd; zf=zipfile.ZipFile('/path/to/2021-2022.zip'); n=[x for x in zf.namelist() if 'ks2final' in x.lower() and x.endswith('.csv')][0]; df=pd.read_csv(zf.open(n), dtype=str, nrows=5); import sys; sys.path.insert(0,'pipeline/plugins/extractors/tap-uk-ees'); from tap_uk_ees.tap import _LEGACY_KS2_COLUMN_MAP as m; missing=[c for c in m if c not in df.columns]; print('missing legacy columns:', missing)"`
Expected: `missing legacy columns: []` (progress columns `READPROG` etc. may legitimately be missing/blank in 2021/22 — acceptable, they load as NULL).
- [ ] **Step 2: Upload the archive to the filebrowser and add the config entry**
In `pipeline/meltano.yml` under `legacy_ks2_urls`, add (with the real share URL from the filebrowser upload):
```yaml
"202122": "http://10.0.1.224:8081/filebrowser/api/public/dl/<SHARE_ID>?inline=true"
```
- [ ] **Step 3: Commit**
```bash
git add pipeline/meltano.yml
git commit -m "feat(pipeline): load 2021/22 school-level KS2 from legacy performance tables"
```
- [ ] **Step 4 (only if Task 1(b) showed 2022/23 subject labels differ):** widen the subject matchers in `stg_ees_ks2.sql` the same way GPS already is (`subject ilike '%grammar%' or subject = 'GPS'`), e.g. `subject in ('Reading', 'reading')` → use the diagnosed labels. Parse-gate and commit as `fix(pipeline): match 2022/23 KS2 subject labels`.
---
### Task 7: Ofsted report-card columns (rc_*)
Resolves the tap TODO (`stg_ofsted_inspections.sql:37`). The marts/backed columns already exist as stubs; this wires real values. Uses Task 1(c)'s confirmed MI column names — the candidates below follow the MI file's existing naming style and must be corrected against the diagnostic output.
**Files:**
- Modify: `pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py` (COLUMN_PRIORITY ~line 19-72, schema ~line 100-114)
- Create: `pipeline/transform/macros/parse_report_card_grade.sql`
- Modify: `pipeline/transform/models/staging/stg_ofsted_inspections.sql:36-46`
**Interfaces:**
- Produces mart columns (already declared in `fact_ofsted_inspection`): `rc_safeguarding_met boolean`, and `rc_inclusion``rc_sixth_form` as integers on the 5-point scale `1=Exceptional, 2=Strong standard, 3=Expected standard, 4=Needs attention/Attention needed, 5=Urgent improvement`. The backend translates codes to labels (same pattern as `gias_codes.py`), verifying wording against Ofsted's published toolkit (spec §8.4).
- [ ] **Step 1: Add tap column mappings**
In `COLUMN_PRIORITY` add (replace candidate strings with Task 1(c)'s exact headers — keep them as priority lists so older files degrade to blank):
```python
"rc_safeguarding_met": ["Report card safeguarding", "Safeguarding"],
"rc_inclusion": ["Report card inclusion", "Inclusion"],
"rc_curriculum_teaching": ["Report card curriculum and teaching", "Curriculum and teaching"],
"rc_achievement": ["Report card achievement", "Achievement"],
"rc_attendance_behaviour": ["Report card attendance and behaviour", "Attendance and behaviour"],
"rc_personal_development": ["Report card personal development and well-being", "Personal development and well-being"],
"rc_leadership_governance": ["Report card leadership and governance", "Leadership and governance"],
"rc_early_years": ["Report card early years", "Early years"],
"rc_sixth_form": ["Report card sixth form", "Sixth form"],
```
And in the stream schema (next to `report_url`, ~line 114):
```python
th.Property("rc_safeguarding_met", th.StringType),
th.Property("rc_inclusion", th.StringType),
th.Property("rc_curriculum_teaching", th.StringType),
th.Property("rc_achievement", th.StringType),
th.Property("rc_attendance_behaviour", th.StringType),
th.Property("rc_personal_development", th.StringType),
th.Property("rc_leadership_governance", th.StringType),
th.Property("rc_early_years", th.StringType),
th.Property("rc_sixth_form", th.StringType),
```
- [ ] **Step 2: Write the grade-parsing macro**
`pipeline/transform/macros/parse_report_card_grade.sql`:
```sql
{% macro parse_report_card_grade(column_name) %}
case lower(trim(nullif({{ column_name }}, 'NULL')))
when 'exceptional' then 1
when 'strong standard' then 2
when 'expected standard' then 3
when 'needs attention' then 4
when 'attention needed' then 4
when 'urgent improvement' then 5
end
{% endmacro %}
```
- [ ] **Step 3: Wire staging**
Replace `stg_ofsted_inspections.sql` lines 36-46 (the NULL stubs) with:
```sql
-- Report Card fields (post-Nov 2025 framework), 5-point scale:
-- 1 Exceptional · 2 Strong standard · 3 Expected standard
-- · 4 Needs attention · 5 Urgent improvement
(lower(trim(nullif(rc_safeguarding_met, 'NULL'))) = 'met') as rc_safeguarding_met,
{{ parse_report_card_grade('rc_inclusion') }}::integer as rc_inclusion,
{{ parse_report_card_grade('rc_curriculum_teaching') }}::integer as rc_curriculum_teaching,
{{ parse_report_card_grade('rc_achievement') }}::integer as rc_achievement,
{{ parse_report_card_grade('rc_attendance_behaviour') }}::integer as rc_attendance_behaviour,
{{ parse_report_card_grade('rc_personal_development') }}::integer as rc_personal_development,
{{ parse_report_card_grade('rc_leadership_governance') }}::integer as rc_leadership_governance,
{{ parse_report_card_grade('rc_early_years') }}::integer as rc_early_years,
{{ parse_report_card_grade('rc_sixth_form') }}::integer as rc_sixth_form,
```
Note `rc_safeguarding_met` becomes boolean (NULL when blank) — matching `fact_ofsted_inspection`'s `rc_safeguarding_met` Boolean column. If `fact_ofsted_inspection.sql` casts these columns, align its casts too (inspect that model; it currently passes the text stubs through).
- [ ] **Step 4: Parse gate + tap smoke test**
Run: `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .``Done.`
Run: `python -c "import sys; sys.path.insert(0,'pipeline/plugins/extractors/tap-uk-ofsted'); from tap_uk_ofsted.tap import COLUMN_PRIORITY; assert 'rc_inclusion' in COLUMN_PRIORITY; print('ok')"``ok`
- [ ] **Step 5: Commit**
```bash
git add pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py pipeline/transform/macros/parse_report_card_grade.sql pipeline/transform/models/staging/stg_ofsted_inspections.sql
git commit -m "feat(pipeline): extract Ofsted report-card judgements (rc_* columns)"
```
---
### Task 8: PR + post-merge verification
**Files:** none new
- [ ] **Step 1: Push and open the PR** (Gitea — use the git credential helper + basic-auth API pattern; token-header auth 401s):
```bash
git push -u origin feat/compare-data-foundation
# then create the PR via the Gitea API with basic auth from `git credential fill`
```
PR body: link spec §5/§8, list the new mart columns, note the Task 6 human dependency (filebrowser upload) and the Task 1 findings file.
- [ ] **Step 2: After merge, verify the DAG run picked everything up**
The daily/monthly DAGs rebuild the affected models (`pipeline/dags/school_data_pipeline.py`). Spot-check via the public API (production after promotion, staging first at stx.schoolcompare.co.uk — note external /api is broken at the staging proxy, so check staging from the host):
```bash
# 2015/16 national row exists
curl -sL "https://www.schoolcompare.co.uk/api/national-averages" | python3 -c "import json,sys; d=json.load(sys.stdin); assert any(r['year']==201516 and r['primary'] for r in d['by_year']), '2015/16 missing'; print('201516 ok')"
# 2021/22 school rows exist (Barclay)
curl -sL "https://www.schoolcompare.co.uk/api/schools/138690" | python3 -c "import json,sys; d=json.load(sys.stdin); ys=[r['year'] for r in d['yearly_data']]; assert 202122 in [int(y) for y in ys], ys; print('202122 ok')"
```
(The admissions/CI/KS4/rc_* columns aren't API-visible until the backend PR maps them — verify those directly in Postgres from the pipeline host: `select count(*) from marts.fact_admissions where second_preference_offers is not null;` etc.)
- [ ] **Step 3: Update the spec** — tick off the §5 promotions this PR delivered (edit the spec's promotion list to note "landed in PR #NN") and commit to main via a docs PR or alongside the backend PR.
---
## Out of scope (next plans)
1. **Backend PR:** map new columns in `backend/models.py`, extend `/api/compare` with supplementary blocks + `national_averages`, computed benchmarks (FSM/EAL/SEN/size medians, disadvantaged national average), CI-based progress banding, report-card label translation (verify against Ofsted toolkit), Ofsted provider-page URLs, graded-vs-ungraded surfacing.
2. **Frontend PR:** rebuild `/compare` per the mockups + e2e journeys (promotion gate).
3. **Separate bug fix:** third school's series not rendering on the current production chart.
4. **Post-v1 (spec):** census ethnicity/young-carer promotion, IDACI display, attendance section, gender-split/absence tier-2 measures.
5. **Already in marts, no work needed:** KS4 EBacc entry/APS, grade 5+ English & maths, Progress 8 CIs — `fact_ks4_performance` carries them today; only the backend needs to expose them.
@@ -0,0 +1,399 @@
# Compare API Enrichment (Backend PR) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Expose the PR #32 data through the API so the redesigned compare screen can be built: enrich `/api/compare` with supplementary blocks + national averages + computed benchmarks, translate Ofsted report-card codes to labels, and surface the new mart columns (spec §6, §8 of `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md`).
**Architecture:** All changes are additive API fields — existing consumers keep working. One small dbt change rides along: `fact_performance` (the combined KS2+KS4 mart the backend's `_MAIN_QUERY` reads) enumerates columns explicitly and was not extended in PR #32, so the new KS2 CI and KS4 banding columns must be threaded through it here. Everything else is backend Python: `models.py` mappings, `data_loader` query/supplementary additions, an Ofsted label dictionary (gias_codes pattern), and `/api/compare` composition.
**Tech Stack:** FastAPI, SQLAlchemy, pandas; dbt (one model); pytest via `python -m pytest backend/tests -q` (CI installs `requirements.txt pytest "httpx<0.28"`; locally use `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q`).
## Global Constraints
- **Never push to `main`.** Branch: `feat/compare-api-enrichment`.
- **Additive only** to API responses; never rename/remove existing fields (frontend + e2e depend on them).
- **Report-card scale labels are the live-sampled vocabulary** (evidence in `pipeline/scripts/diagnose_compare_gaps.py`): `1=Exceptional, 2=Strong standard, 3=Expected standard, 4=Needs attention, 5=Urgent improvement`. Never "Attention needed". Safeguarding is boolean met/not-met, never counted as a graded area.
- **Ofsted links** are always the provider page `https://reports.ofsted.gov.uk/provider/21/{urn}` (spec §5) labelled as the school's Ofsted page.
- **Benchmark provenance** (spec §8.6): computed values are "state-school average (computed from our dataset)" — the API must expose them under a `benchmarks` key, clearly separate from official `national_averages`.
- TDD: each behaviour lands with a failing test first, in `backend/tests/` following the `test_school_details.py` pattern (pandas fixture + monkeypatched `load_school_data` + `TestClient`).
- Deploy note for the PR body: the new API fields return NULL/empty until prod's DAGs have run post-promotion.
---
### Task 0: Branch
- [ ] `git checkout main && git pull && git checkout -b feat/compare-api-enrichment` (commit this plan file on the branch).
---
### Task 1: Thread PR #32 columns through `fact_performance`
**Files:**
- Modify: `pipeline/transform/models/marts/fact_performance.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_performance block, if it has one — add the columns wherever the model's other columns are listed; if the model has no column list there, skip the yml)
**Interfaces:**
- Produces (for `_MAIN_QUERY` in Task 4): `ks2.*` CI columns and `ks4.progress_8_banding`, `ks4.attainment_8_disadvantage_gap`, `ks4.progress_8_disadvantage_gap` on `marts.fact_performance`.
- [ ] **Step 1:** In `fact_performance.sql`, after `ks2.reading_progress,` add `ks2.reading_progress_lower_ci,` and `ks2.reading_progress_upper_ci,`; after `ks2.writing_progress,` add `ks2.writing_progress_lower_ci,`, `ks2.writing_progress_upper_ci,`, `ks2.writing_working_towards_pct,`; after `ks2.maths_progress,` add `ks2.maths_progress_lower_ci,`, `ks2.maths_progress_upper_ci,`. In the KS4 section, after the `ks4.progress_8_upper_ci`-equivalent line (locate the Progress 8 block) add:
```sql
ks4.progress_8_banding,
ks4.attainment_8_disadvantage_gap,
ks4.progress_8_disadvantage_gap,
```
- [ ] **Step 2:** Parse gate: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` → exit 0.
- [ ] **Step 3:** Commit: `feat(pipeline): thread compare-foundation columns through fact_performance`
---
### Task 2: ORM mappings for the new mart columns
**Files:**
- Modify: `backend/models.py` (`KS2Performance` after `maths_progress`; `FactAdmissions` after `first_preference_offers`)
- Test: none (declarative mappings; covered by Task 4's query tests)
**Interfaces:**
- Produces attributes used by Task 4: `KS2Performance.reading_progress_lower_ci``maths_progress_upper_ci`, `writing_working_towards_pct` (Float); `FactAdmissions.total_offers`, `.second_preference_offers`, `.third_preference_offers`, `.cross_la_applications`, `.cross_la_offers` (Integer).
- [ ] **Step 1:** Add to `KS2Performance` (next to the existing progress columns):
```python
reading_progress_lower_ci = Column(Float)
reading_progress_upper_ci = Column(Float)
writing_progress_lower_ci = Column(Float)
writing_progress_upper_ci = Column(Float)
writing_working_towards_pct = Column(Float)
maths_progress_lower_ci = Column(Float)
maths_progress_upper_ci = Column(Float)
```
Add to `FactAdmissions` (after `first_preference_offers`):
```python
total_offers = Column(Integer)
second_preference_offers = Column(Integer)
third_preference_offers = Column(Integer)
cross_la_applications = Column(Integer)
cross_la_offers = Column(Integer)
```
(`FactOfstedInspection` already maps all `rc_*` columns with the right types — verify, don't change.)
- [ ] **Step 2:** Commit: `feat(api): map compare-foundation mart columns`
---
### Task 3: Ofsted label dictionary + provider URL (TDD)
**Files:**
- Create: `backend/ofsted_codes.py`
- Test: `backend/tests/test_ofsted_codes.py`
**Interfaces:**
- Produces for Task 4: `REPORT_CARD_GRADE_NAMES: dict[int, str]`, `report_card_labels(ofsted: dict) -> dict` (returns `{area_key: {"code": int, "label": str}}` for the non-null `rc_*` grade fields, excluding safeguarding), `ofsted_page_url(urn: int) -> str`.
- [ ] **Step 1: Failing tests**
```python
"""Report-card code translation uses the live-sampled Ofsted vocabulary
(pipeline/scripts/diagnose_compare_gaps.py TASK 7 VALUE SAMPLE):
Exceptional / Strong standard / Expected standard / Needs attention /
Urgent improvement — never the consultation draft's 'Attention needed'."""
from backend.ofsted_codes import (
REPORT_CARD_GRADE_NAMES, report_card_labels, ofsted_page_url,
)
def test_scale_is_sampled_vocabulary():
assert REPORT_CARD_GRADE_NAMES == {
1: "Exceptional",
2: "Strong standard",
3: "Expected standard",
4: "Needs attention",
5: "Urgent improvement",
}
def test_labels_only_for_populated_areas_and_never_safeguarding():
ofsted = {
"rc_achievement": 2,
"rc_inclusion": 3,
"rc_attendance_behaviour": 4,
"rc_early_years": None,
"rc_safeguarding_met": True,
"overall_effectiveness": None,
}
labels = report_card_labels(ofsted)
assert labels == {
"rc_achievement": {"code": 2, "label": "Strong standard"},
"rc_inclusion": {"code": 3, "label": "Expected standard"},
"rc_attendance_behaviour": {"code": 4, "label": "Needs attention"},
}
def test_unknown_code_is_skipped_not_crashed():
assert report_card_labels({"rc_achievement": 9}) == {}
def test_provider_url():
assert ofsted_page_url(138690) == "https://reports.ofsted.gov.uk/provider/21/138690"
```
- [ ] **Step 2:** Run `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_ofsted_codes.py -q` → FAIL (module missing).
- [ ] **Step 3: Implement `backend/ofsted_codes.py`**
```python
"""Ofsted renewed-framework (Nov 2025) report-card code translation.
Scale labels are the live-sampled vocabulary from the Ofsted MI file
(see pipeline/scripts/diagnose_compare_gaps.py, TASK 7 VALUE SAMPLE) —
verified against real data, not the consultation draft.
"""
REPORT_CARD_GRADE_NAMES = {
1: "Exceptional",
2: "Strong standard",
3: "Expected standard",
4: "Needs attention",
5: "Urgent improvement",
}
# Graded evaluation areas only — safeguarding is a separate boolean
# judgement and must never appear in grade counts or label maps.
_RC_AREA_KEYS = (
"rc_inclusion",
"rc_curriculum_teaching",
"rc_achievement",
"rc_attendance_behaviour",
"rc_personal_development",
"rc_leadership_governance",
"rc_early_years",
"rc_sixth_form",
)
def report_card_labels(ofsted: dict) -> dict:
"""{area_key: {code, label}} for populated, known-valued rc_* areas."""
out = {}
for key in _RC_AREA_KEYS:
code = ofsted.get(key)
label = REPORT_CARD_GRADE_NAMES.get(code)
if code is not None and label is not None:
out[key] = {"code": code, "label": label}
return out
def ofsted_page_url(urn: int) -> str:
"""The school's page on ofsted.gov.uk (all its reports live there —
we never deep-link an individual report; spec §5)."""
return f"https://reports.ofsted.gov.uk/provider/21/{urn}"
```
- [ ] **Step 4:** Re-run the test file → 4 passed. Run the full suite (same command, `backend/tests -q`) → all pass.
- [ ] **Step 5:** Commit: `feat(api): Ofsted report-card labels and provider-page URL`
---
### Task 4: data_loader — query columns + richer supplementary blocks (TDD)
**Files:**
- Modify: `backend/data_loader.py` (`_MAIN_QUERY` ~line 153; `get_supplementary_data` ~line 460)
- Test: `backend/tests/test_supplementary_enrichment.py`
**Interfaces:**
- `_MAIN_QUERY` additionally selects (KS2 block, after `p.maths_progress`): `p.reading_progress_lower_ci, p.reading_progress_upper_ci, p.writing_progress_lower_ci, p.writing_progress_upper_ci, p.writing_working_towards_pct, p.maths_progress_lower_ci, p.maths_progress_upper_ci`; (KS4 block, after the Progress 8 CI columns): `p.progress_8_banding, p.attainment_8_disadvantage_gap, p.progress_8_disadvantage_gap`. Note `_MAIN_QUERY_NO_SIXTH_FORM`/`_MAIN_QUERY_LEGACY_NAMES` are string-derived from `_MAIN_QUERY` (lines 259-270) and inherit automatically — verify the assertions there still hold.
- `get_supplementary_data(db, urn)["admissions"]` rows additionally carry: `total_offers`, `second_preference_offers`, `third_preference_offers`, `cross_la_applications`, `cross_la_offers` (add to `_admissions_row`).
- `get_supplementary_data(db, urn)["ofsted"]` additionally carries: `report_card` (the `report_card_labels(...)` dict, `{}` when no rc data), `ofsted_page_url`, and `grade_source`: `"graded"` when `overall_effectiveness` came from the graded column, `"ungraded_carried_forward"` when the fallback `ungraded_grade` supplied it, `None` when neither.
- [ ] **Step 1: Failing tests** — construct a fake Ofsted row object (simple `types.SimpleNamespace` with the model's attributes) and call the block-building logic via `get_supplementary_data` with a stubbed session (follow how existing tests stub the db; if none do, factor the ofsted-dict construction into a pure helper `_ofsted_block(o, urn)` and test that directly — preferred):
```python
import types
from backend.data_loader import _ofsted_block
def _row(**kw):
base = dict(
framework="RC", inspection_date=None, inspection_type=None,
overall_effectiveness=None, quality_of_education=None,
behaviour_attitudes=None, personal_development=None,
leadership_management=None, early_years_provision=None,
sixth_form_provision=None, ungraded_outcome=None, ungraded_grade=None,
rc_safeguarding_met=None, rc_inclusion=None, rc_curriculum_teaching=None,
rc_achievement=None, rc_attendance_behaviour=None,
rc_personal_development=None, rc_leadership_governance=None,
rc_early_years=None, rc_sixth_form=None, report_url=None,
)
base.update(kw)
return types.SimpleNamespace(**base)
def test_report_card_block_and_provider_url():
o = _row(rc_achievement=2, rc_inclusion=3, rc_safeguarding_met=True)
block = _ofsted_block(o, urn=100140)
assert block["report_card"]["rc_achievement"]["label"] == "Strong standard"
assert "rc_safeguarding_met" not in block["report_card"]
assert block["rc_safeguarding_met"] is True
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140"
def test_grade_source_graded_vs_carried_forward():
assert _ofsted_block(_row(overall_effectiveness=1), urn=1)["grade_source"] == "graded"
carried = _ofsted_block(_row(ungraded_grade=2), urn=1)
assert carried["grade_source"] == "ungraded_carried_forward"
assert carried["overall_effectiveness"] == 2
assert _ofsted_block(_row(), urn=1)["grade_source"] is None
def test_admissions_row_new_fields():
from backend.data_loader import _admissions_row_dict
a = types.SimpleNamespace(
year=202627, school_phase="Primary", places_offered=80,
total_applications=185, first_preference_applications=74,
first_preference_offers=74, first_preference_offer_pct=100.0,
oversubscription_ratio=0.925, oversubscribed=False,
total_offers=80, second_preference_offers=4, third_preference_offers=2,
cross_la_applications=12, cross_la_offers=3,
)
d = _admissions_row_dict(a)
for k in ("total_offers", "second_preference_offers", "third_preference_offers",
"cross_la_applications", "cross_la_offers"):
assert d[k] == getattr(a, k)
```
- [ ] **Step 2:** Run → FAIL (helpers don't exist).
- [ ] **Step 3: Implement.** Refactor the existing inline ofsted-dict construction in `get_supplementary_data` into a module-level `_ofsted_block(o, urn)` that produces the existing keys **unchanged** plus the three new ones (`report_card` via `report_card_labels(...)` from Task 3, `ofsted_page_url` via `ofsted_page_url(urn)`, `grade_source` per the interface rule — derived from which source supplied `overall_effectiveness`). Rename/extract the local `_admissions_row` into module-level `_admissions_row_dict(a)` and append the five new fields. Add the ten new columns to `_MAIN_QUERY` exactly as the interface lists them. `get_supplementary_data` calls both helpers; its external shape gains only additive keys.
- [ ] **Step 4:** Full suite → all pass (existing `test_school_details.py` etc. must not break; if a fixture enumerates yearly-data columns, extend it with the new NaN columns as needed).
- [ ] **Step 5:** Commit: `feat(api): expose progress CIs, KS4 banding/gaps, admissions detail, report-card labels`
---
### Task 5: Computed benchmarks helper (TDD)
**Files:**
- Modify: `backend/data_loader.py` (new function)
- Test: `backend/tests/test_benchmarks.py`
**Interfaces:**
- Produces for Task 6: `compute_benchmarks(df) -> dict` — pure function over the main dataframe (latest year, state schools), shape:
```python
{
"source": "state-school average (computed from our dataset)",
"year": 202425,
"primary": {
"disadvantaged_rwm_expected_pct": 46.1, # weighted by eligible_pupils
"eal_pct": 22.3, # median
"sen_support_pct": 14.0, # median
"disadvantaged_pct": 24.8, # median (FSM6 proxy)
"median_pupils": 281, # median school size
},
"secondary": { "median_pupils": 1024, "eal_pct": ..., "sen_support_pct": ..., "disadvantaged_pct": ... },
}
```
- [ ] **Step 1: Failing tests** — build a small synthetic df (6 primary rows with known eligible_pupils/rwm_expected_disadvantaged_pct so the weighted average is hand-checkable; a couple of secondary rows flagged by non-null `attainment_8_score`), assert: weighted disadvantaged average matches hand computation (not the unweighted mean), medians ignore NaN, secondary block lacks the disadvantaged-RWM key, latest-year filtering (rows from an older year must not affect results), and empty df → `{}`.
- [ ] **Step 2:** Run → FAIL.
- [ ] **Step 3: Implement** in `data_loader.py`:
```python
def compute_benchmarks(df: pd.DataFrame) -> dict:
"""State-school benchmarks computed from our dataset (spec §5/§8.6).
These are NOT official DfE figures — consumers must label them
'state-school average (computed from our dataset)'."""
if df.empty or "year" not in df.columns:
return {}
latest_year = df["year"].max()
d = df[df["year"] == latest_year]
if d.empty:
return {}
is_secondary = d["attainment_8_score"].notna() if "attainment_8_score" in d.columns else pd.Series(False, index=d.index)
prim, sec = d[~is_secondary], d[is_secondary]
def _median(sub, col):
if col not in sub.columns:
return None
v = sub[col].median()
return round(float(v), 1) if pd.notna(v) else None
def _weighted_disadvantaged(sub):
if not {"rwm_expected_disadvantaged_pct", "eligible_pupils"} <= set(sub.columns):
return None
s = sub.dropna(subset=["rwm_expected_disadvantaged_pct", "eligible_pupils"])
if s.empty or s["eligible_pupils"].sum() == 0:
return None
w = (s["rwm_expected_disadvantaged_pct"] * s["eligible_pupils"]).sum() / s["eligible_pupils"].sum()
return round(float(w), 1)
def _block(sub, with_disadvantaged):
block = {
"eal_pct": _median(sub, "eal_pct"),
"sen_support_pct": _median(sub, "sen_support_pct"),
"disadvantaged_pct": _median(sub, "disadvantaged_pct"),
"median_pupils": int(sub["total_pupils"].median()) if "total_pupils" in sub.columns and pd.notna(sub["total_pupils"].median()) else None,
}
if with_disadvantaged:
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
return block
return {
"source": "state-school average (computed from our dataset)",
"year": int(latest_year),
"primary": _block(prim, with_disadvantaged=True),
"secondary": _block(sec, with_disadvantaged=False),
}
```
(Adapt column presence to the real df — `sen_support_pct` reaches the df via `_MAIN_QUERY`; confirm and add it there if the KS2 block doesn't already select it, mirroring Task 4's additions.)
- [ ] **Step 4:** Full suite → pass. **Step 5:** Commit: `feat(api): computed state-school benchmarks`
---
### Task 6: Enrich `/api/compare` + expose GPS/science national averages (TDD)
**Files:**
- Modify: `backend/app.py` (`compare_schools` ~line 636; `get_national_averages` ~line 730)
- Test: `backend/tests/test_compare_enrichment.py`
**Interfaces (response additions, all additive):**
- `/api/compare` top level gains: `"national_averages"` (same payload the `/api/national-averages` endpoint returns — extract the endpoint body into a helper `_national_averages_payload(df)` and reuse; do not duplicate the logic) and `"benchmarks"` (Task 5's `compute_benchmarks(df)`).
- Each `comparison[urn]` gains: `"ofsted"`, `"census"`, `"admissions"`, `"admissions_history"`, `"deprivation"` from `get_supplementary_data` (one `SessionLocal()` for the whole request, closed in `finally`; on exception the five keys are `None`/`[]` — mirror the detail endpoint's defensive pattern at app.py:583-590).
- `get_national_averages`' KS2 metric list gains `"gps_expected_pct", "gps_high_pct", "science_expected_pct"` so the England ticks for GPS/science flow once the data exists.
- [ ] **Step 1: Failing tests** — monkeypatch `load_school_data` with a two-school primary df (reuse/extend the fixture style of `test_school_details.py`) and monkeypatch `get_supplementary_data` to a canned dict; assert on `TestClient(app).get("/api/compare?urns=...")`:
- response keeps the existing shape (`comparison[urn]["school_info"]["rwm_expected_pct"]` etc.),
- each school gains the five supplementary keys (canned values round-tripped),
- top-level `national_averages` and `benchmarks` present; `benchmarks["source"]` is the exact provenance string,
- a supplementary-layer exception (monkeypatched to raise) degrades to `ofsted: None` etc. with HTTP 200,
- `/api/national-averages` includes `gps_expected_pct` in the primary block when the df/national table provides it (monkeypatch the national-averages source the endpoint reads).
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement per the interfaces. **Step 4:** Full suite → pass.
- [ ] **Step 5:** Commit: `feat(api): compare endpoint carries supplementary blocks, national averages and benchmarks`
---
### Task 7: PR + verification
- [ ] **Step 1:** Full suite one more time + `uv run --with pyyaml python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/deploy.yml'))"` sanity is NOT needed (no workflow changes) — instead run the dbt parse gate again (Task 1 file).
- [ ] **Step 2:** Push, open PR via the Gitea API (credential-helper basic auth). PR body: the new response shapes (one JSON sketch), the reused-not-duplicated national-averages helper, the provenance rule for benchmarks, deploy note (fields NULL until prod DAGs run post-promotion), and that no e2e change is needed (no user-facing behaviour changes — the compare UI still reads the old fields; the frontend PR carries the journey updates).
- [ ] **Step 3:** After merge + staging deploy: `curl -s https://stx.schoolcompare.co.uk/api/compare?urns=138690,100140 | python3 -m json.tool | head -80` — verify the new keys and that `benchmarks.primary.disadvantaged_rwm_expected_pct` is plausible (~45-47). Verify `/api/national-averages` now carries `gps_expected_pct`/`science_expected_pct` (values or honest nulls if DfE suppresses them at national level).
---
## Out of scope
- Frontend rebuild + e2e journeys (next PR — consumes everything this PR exposes).
- `schemas.py` METRIC_DEFINITIONS additions for the trends picker (frontend PR decides which of the new columns become picker metrics).
- CI-based progress banding logic (frontend computes Above/Average/Below from the CI columns; historical years only).
@@ -0,0 +1,287 @@
# Compare Screen Frontend Rebuild Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebuild `/compare` in the Next.js app to match the approved mockups — parent-first sections (At a glance / Ofsted / Academics / Getting a place / Who goes there / Explore trends), England-average anchoring with provenance-correct labels, mobile-first measure-first layout — consuming the enriched `/api/compare` payload from PR #34, with e2e journeys updated in the same PR (they are the promotion gate).
**Architecture:** `ComparisonView` becomes an assembly of section components fed by one enriched fetch. All comprehension rules from the two expert reviews live in a pure, jest-tested module (`lib/compareLogic.ts`) — components stay presentational. The mockups are committed at `docs/superpowers/specs/mockups/compare-desktop.html` and `compare-mobile.html`: **all user-facing copy (labels, tooltips, chips, footnote wording) is taken verbatim from them** — they carry two rounds of education-expert review; do not paraphrase.
**Tech Stack:** Next.js (app router, SSR page + client view), CSS modules, Chart.js (existing `ComparisonChart`), Jest (`npm test` in `nextjs-app/`), Playwright e2e (`e2e/`).
## Global Constraints
- **Never push to `main`.** Branch: `feat/compare-frontend-rebuild`.
- **Copy is expert-reviewed:** take it verbatim from the committed mockups. Binding rules (spec §8): Ofsted scale labels come from the API's `report_card[..].label` (never hardcode area labels beyond the mockups'); official DfE numbers say "England average", computed ones say "state-school average (computed from our dataset)"; the 2021/22 chart gap note says "DfE didn't publish school-level figures for 2021/22"; never derive an overall grade from report-card areas; safeguarding never counts as a graded area; "Latest Ofsted inspection", "EHC plans", "at or above capacity", "Over 1 in 4", "first choice (officially 'first preference')".
- **Mobile-first:** the measure-first stacked layout (mobile mockup) is the base CSS; the desktop label-column grid is the `min-width` enhancement.
- **URL contract unchanged:** `?urns=` (and `metric=` now scoped to Explore trends) keep working; share flow, `useComparison` basket, phase tabs, and `compare_viewed`/`compare_metric_changed` analytics events are preserved.
- **Do not run a local server** (CLAUDE.md); verification = jest + `tsc` + the e2e suite against staging after merge. e2e must pass on **staging data** — remember staging has partial history: assert against the *latest* year, never oldest.
- Existing `/api/compare` consumers elsewhere in the app (SchoolDetail links, toasts) must not break — the response is additive, and this PR only rewrites the compare page's own components.
- **Post-v1 (do not build):** IDACI, attendance section, gender-split/absence tier-2 measures, finance (spec §4).
---
### Task 0: Branch + design sources
- [ ] `git checkout main && git pull && git checkout -b feat/compare-frontend-rebuild`
- [ ] The mockups and this plan are already in the working tree (`docs/superpowers/specs/mockups/compare-{desktop,mobile}.html`) — commit them: `docs: compare mockups as frontend design source + rebuild plan`
---
### Task 1: Types for the enriched payload
**Files:**
- Modify: `nextjs-app/lib/types.ts` (extend `SchoolResult`, `ComparisonData`, `ComparisonResponse` — located around lines 293-314)
**Interfaces (produced for every later task):**
```ts
export interface ReportCardEntry { code: number; label: string; }
export interface OfstedBlock {
framework: string | null;
inspection_date: string | null;
inspection_type: string | null;
overall_effectiveness: number | null;
grade_source: 'graded' | 'ungraded_carried_forward' | null;
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;
rc_safeguarding_met: boolean | null;
report_card: Record<string, ReportCardEntry>;
ofsted_page_url: string;
report_url: string | null;
}
export interface CensusBlock {
year: number | null; total_pupils: number | null;
female_pupils: number | null; male_pupils: number | null;
fsm_pct: number | null; eal_pct: number | null;
}
export interface AdmissionsRow {
year: number; school_phase: string | null;
places_offered: number | null; total_applications: number | null;
first_preference_applications: number | null; first_preference_offers: number | null;
first_preference_offer_pct: number | null; oversubscription_ratio: number | null;
oversubscribed: boolean | null;
total_offers: number | null; second_preference_offers: number | null;
third_preference_offers: number | null;
cross_la_applications: number | null; cross_la_offers: number | null;
}
export interface DeprivationBlock {
lsoa_code: string | null; idaci_score: number | null; idaci_decile: number | null;
}
export interface BenchmarkBlock {
eal_pct: number | null; sen_support_pct: number | null;
disadvantaged_pct: number | null; median_pupils: number | null;
disadvantaged_rwm_expected_pct?: number | null;
}
export interface Benchmarks {
source: string; year: number;
primary: BenchmarkBlock; secondary: BenchmarkBlock;
}
export interface NationalAverages {
year: number;
primary: Record<string, number>;
secondary: Record<string, number>;
by_year: Array<{ year: number; primary: Record<string, number>; secondary: Record<string, number> }>;
}
```
- [ ] **Step 1:** Add the interfaces above; extend `ComparisonData` with optional `ofsted?: OfstedBlock | null; census?: CensusBlock | null; admissions?: AdmissionsRow | null; admissions_history?: AdmissionsRow[]; deprivation?: DeprivationBlock | null;` and `ComparisonResponse` with `national_averages?: NationalAverages; benchmarks?: Benchmarks;` (optional so the UI degrades on an old backend). Extend `SchoolResult` with the ten new yearly columns (`reading_progress_lower_ci``maths_progress_upper_ci`, `writing_working_towards_pct`, `progress_8_banding: string | null`, `attainment_8_disadvantage_gap`, `progress_8_disadvantage_gap`).
- [ ] **Step 2:** `cd nextjs-app && npx tsc --noEmit` → clean. Commit: `feat(compare): types for enriched comparison payload`
---
### Task 2: `lib/compareLogic.ts` — the comprehension rules, jest-tested
**Files:**
- Create: `nextjs-app/lib/compareLogic.ts`
- Test: `nextjs-app/__tests__/lib/compareLogic.test.ts`
**Interfaces (produced):**
```ts
export type Verdict = 'above' | 'close' | 'below';
export function verdict(value: number, anchor: number, tolerance?: number): Verdict; // default tolerance 2pp
// Report-card summary per spec §4.2: count graded areas per label (best
// first), NAME any 'Needs attention'/'Urgent improvement' area, safeguarding
// separate, "No areas need attention" reassurance when applicable.
export interface ReportCardSummary {
counts: Array<{ label: string; count: number }>; // best grade first
problems: Array<{ areaLabel: string; label: string }>; // named, never counted-away
safeguarding: 'met' | 'not_met' | null;
allClear: boolean;
}
export function summariseReportCard(ofsted: OfstedBlock): ReportCardSummary;
// One display model for all three inspection regimes.
export type OfstedDisplay =
| { kind: 'none' }
| { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false }
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
| { kind: 'report_card'; summary: ReportCardSummary };
export function ofstedDisplay(ofsted: OfstedBlock | null | undefined): OfstedDisplay;
export const OFSTED_LEGACY_GRADES: Record<number, string>; // 1 Outstanding, 2 Good, 3 Requires improvement, 4 Inadequate
// Human-readable area label from an rc_ key: 'rc_attendance_behaviour' →
// 'Attendance & behaviour' (mapping table copied from the mockups' area rows).
export function rcAreaLabel(key: string): string;
// Admissions, one consistent chip metric (first-preference success).
export interface AdmissionsSummary {
firstPrefPct: number | null;
chip: { tone: 'good' | 'warn' | 'neutral'; text: string } | null; // "97% of first choices offered" / "Over 1 in 4 first choices missed out" wording per mockups
interest: string | null; // "Named on 457 forms · 180 places"
}
export function summariseAdmissions(a: AdmissionsRow | null | undefined): AdmissionsSummary;
// CI-based progress band for historical years (null when no CI published).
export function progressBand(score: number | null, lower: number | null, upper: number | null):
'above' | 'average' | 'below' | null; // CI entirely >0 → above; entirely <0 → below; straddles → average
// Dot-strip geometry (used by the DotStrip component; pure for testing).
export interface StripPoint { pos: number; labelAbove: boolean; value: number; schoolIndex: number; }
export function stripPositions(values: Array<number | null>, min: number, max: number): StripPoint[];
// pos = (v-min)/(max-min)*100 clamped 0..100; labels within 4% of range of a
// lower neighbour flip above (the mockups' collision nudge).
```
- [ ] **Step 1: Failing tests** covering, at minimum:
- `summariseReportCard`: 4 Strong + 2 Expected + 1 Needs-attention + safeguarding met → counts `[Strong standard×4, Expected standard×2]`, `problems=[{areaLabel:'Attendance & behaviour', label:'Needs attention'}]`, `allClear=false`; safeguarding NEVER in counts; all-Expected+met → `allClear=true`; labels come from the input's `.label` (assert the function never invents "Attention needed").
- `ofstedDisplay`: report_card present → `kind:'report_card'` even if a legacy grade also exists; `grade_source:'ungraded_carried_forward'``carriedForward:true`; null → `'none'`.
- `summariseAdmissions`: 73% → warn chip text `Over 1 in 4 first choices missed out`; 97% → good chip `97% of first choices offered`; 100% → `All first choices offered`; interest string `Named on 342 forms · 120 places`; nulls → null chip.
- `progressBand`: (1.2, 0.4, 2.0)→above; (-1.2, -2.0, -0.4)→below; (0.3, -0.5, 1.1)→average; missing CI → null.
- `stripPositions`: 100120 domain maps 106→30; values 91 and 92 on 0100 → second label flips above; nulls skipped.
- `verdict`: 87 vs 62 → above; 61 vs 62 → close (within 2pp); 40 vs 62 → below.
- [ ] **Step 2:** `cd nextjs-app && npm test -- compareLogic` → FAIL. **Step 3:** implement. **Step 4:** pass + `tsc` clean. **Step 5:** Commit: `feat(compare): comprehension logic (report cards, admissions, verdicts, strips)`
---
### Task 3: `DotStrip` component
**Files:**
- Create: `nextjs-app/components/DotStrip.tsx`, `nextjs-app/components/DotStrip.module.css`
**Interfaces:**
```ts
export interface DotStripProps {
label: string;
values: Array<number | null>; // one per school, school order = chart colour order
anchor?: { value: number; label: string } | null; // e.g. {62, "England 62%"} — omit when benchmark absent
min?: number; max?: number; // default 0..100
unit?: string; // default '%'
tip?: string; // title tooltip on the label
note?: string; // e.g. "(teacher-assessed)" suffix handled by caller in label
}
```
- [ ] Render per the mockups' `.strip-row` anatomy: label row, 4px track, England tick + tick label, 16px dots coloured by `CHART_COLORS[index]` with white ring, value labels below (flipped above on collision via `stripPositions`). `role="img"` + `aria-label` enumerating anchor and each school's value (copy the aria pattern from the mockups). CSS module mirrors the mockup styles using the app's CSS variables (`--border-light`, `--text-muted`, etc.).
- [ ] Jest: render with `@testing-library/react` (already configured — see `__tests__/components/SecondarySchoolRow.test.tsx` for the harness pattern): asserts aria-label content, tick present when anchor given, absent otherwise.
- [ ] Commit: `feat(compare): DotStrip with England-average anchor`
---
### Task 4: Section components — At a glance, Ofsted, Getting a place, Who goes there
**Files:**
- Create: `nextjs-app/components/compare/CompareAtAGlance.tsx` (+ `.module.css`)
- Create: `nextjs-app/components/compare/CompareOfsted.tsx`
- Create: `nextjs-app/components/compare/CompareAdmissions.tsx`
- Create: `nextjs-app/components/compare/CompareCommunity.tsx`
- Create: `nextjs-app/components/compare/compareSections.module.css` (shared measure-first grid)
- Test: `nextjs-app/__tests__/components/CompareOfsted.test.tsx`
**Shared layout contract (all four):** props `{ schools: School[]; data: Record<string, ComparisonData>; benchmarks?: Benchmarks; nationalAverages?: NationalAverages }`. Base CSS is the mobile mockup's measure-first stack (`.measure` card → `.srow` per school with colour dot + short name + value + chip + note); at `min-width: 761px` it becomes the desktop mockup's grid (200px row-label column + one column per school). Section headers use the existing `.section-title` idiom; every section carries its mockup "how" line verbatim.
**Content per section = the mockups, row for row.** Structure/tone rules already encoded in Task 2's helpers:
- *At a glance*: Latest Ofsted inspection row (badge via `ofstedDisplay`; report-card case renders `ReportCardSummary` chips — counts best-first + named problem chips + safeguarding line); expected-standard row (big % + `verdict` chip vs `national_averages.primary.rwm_expected_pct`, small "England average N%"); Getting a place row (chip from `summariseAdmissions`, note = `interest`); Size row (pupils + "at or above capacity"/"N% full" from census/capacity, vs `benchmarks.*.median_pupils` for "larger/smaller than average" phrasing).
- *Ofsted*: the section's `how` paragraph (regime explanation + non-comparability + "Expected standard" disambiguation) verbatim from the desktop mockup; Result row; Inspected row (date + "4+ years ago" chip when >4y, computed from `inspection_date`); Judgement detail row — **one chip-list grammar for both regimes** (legacy subgrades via `OFSTED_LEGACY_GRADES`; report card via `report_card` labels; "We don't hold area-by-area detail for this inspection" when neither); Ofsted page row linking `ofsted_page_url` ("<Name>'s Ofsted page →").
- *Getting a place*: `how` paragraph (first preference/equal preference/offer-day caveats) verbatim; Interest row; first-choice success row with mini bar; "What this means" row (distance note: "check the school's admission criteria (for most non-faith primaries, distance decides)" only when oversubscribed).
- *Who goes there*: pupils-on-roll (census + capacity), girls/boys, FSM (chip vs `benchmarks` with "state-school average" wording), EAL, SEN (tooltip incl. "EHC plans" + specialist-provision note), faith, ages · nursery, run by (trust name or "<LA> council").
- [ ] **Step 1:** Failing jest test for `CompareOfsted` (the riskiest): given one graded school, one carried-forward, one report-card school → asserts the three Result cells ("Outstanding" badge; badge + carried-forward marker; "Report card" + no invented overall grade), the chip-list judgement rows, and the comparability note appearing only for the mixed case.
- [ ] **Step 2-4:** Implement all four sections; test passes; `tsc` clean; `npm test` full suite green.
- [ ] **Step 5:** Commit: `feat(compare): at-a-glance, Ofsted, admissions and community sections`
---
### Task 5: `CompareAcademics` — strips + More measures
**Files:**
- Create: `nextjs-app/components/compare/CompareAcademics.tsx`
- Test: extend `nextjs-app/__tests__/lib/compareLogic.test.ts` with the metric-extraction helper below
**Interfaces:**
- Add to `compareLogic.ts`: `latestValues(data, urns, metricKey) => Array<number|null>` (latest non-null yearly value per school) — tested.
- [ ] Tier 1 strips (always visible), each a `DotStrip` with the England anchor from `national_averages.primary`: RWM expected, Reading, Writing, Maths, "Working at a higher standard than expected" (tooltip: composition sentence from the mockups). Section `how` line: "tests and teacher assessments … writing is assessed by teachers, not tested" verbatim.
- [ ] Tier 2 `<details>` "More measures — grammar, punctuation & spelling, science, average scaled scores": GPS + Science (teacher-assessed, tooltip verbatim) with anchors from `national_averages` **when present, no tick + honest note when null**; scaled scores (reading/maths/GPS) on `min=100 max=120` with the mockups' window caption.
- [ ] Equity row: disadvantaged pupils' RWM per school + chip vs `benchmarks.primary.disadvantaged_rwm_expected_pct` with the "state-school average" wording and small-cohort tooltip verbatim.
- [ ] Secondary phase variant (when active phase is secondary): tier-1 rows are Attainment 8 (anchor `national_averages.secondary.attainment_8_score`), Progress 8 banding (chip showing `progress_8_banding` verbatim — DfE's own label), grade 5+ English & maths %; tier-2: EBacc entry/APS. Measure-first rows (no strips needed for banding).
- [ ] `npm test` + `tsc`; commit: `feat(compare): academics strips with England anchors and More measures`
---
### Task 6: Trends explorer — England line, gap-honest axis, series bug
**Files:**
- Modify: `nextjs-app/components/ComparisonChart.tsx`
- Create: `nextjs-app/components/compare/TrendsExplorer.tsx`
- Test: `nextjs-app/__tests__/components/ComparisonChart.test.tsx`
- [ ] **Step 1 (bug first): root-cause the missing third series** seen on production (3 schools in table, 2 lines on chart). Write a failing jest test: 3 schools whose `yearly_data` year values are floats (`202425.0`) vs the labels array — the suspect is the year-matching in `ComparisonChart.tsx:69` (`years.map(...)` built from school 1 only + strict equality against other schools' years). Fix so every school's series renders and years are the union of all schools' years, sorted.
- [ ] **Step 2:** Add optional `nationalByYear?: Record<number, number|null>` prop → dashed grey "England average" dataset (colour `--text-muted`, `borderDash:[5,4]`, no fill, `spanGaps:false`).
- [ ] **Step 3:** Gap honesty: x-axis category labels include 2019/20 and 2020/21 as empty slots (band label "tests cancelled 2019/202020/21" via a Chart.js annotation-free approach: two category ticks with all-null data and a subtitle note under the chart, copy verbatim: the chart footnote "DfE didn't publish school-level figures for 2021/22" appears when the metric is a KS2 measure and 2021/22 school values are null while the England value exists). `spanGaps:false` on school datasets so dataset gaps break lines.
- [ ] **Step 4:** `TrendsExplorer` wraps the grouped metric picker (existing optgroup structure and `metrics` from `/api/metrics`, existing analytics event) + the chart + the existing year-by-year table, inside a collapsed-by-default `<details>` ("Explore trends"). Progress metrics annotate cells with `progressBand` chips for years where CIs exist.
- [ ] Tests pass; commit: `feat(compare): trends explorer with England line; fix missing series`
---
### Task 7: Assemble the new `ComparisonView`
**Files:**
- Rewrite: `nextjs-app/components/ComparisonView.tsx` (+ its `.module.css`)
- Modify: `nextjs-app/app/compare/page.tsx` metadata description (mention Ofsted/admissions, not just KS2)
- [ ] Preserve intact: `useComparison` basket seeding/URL sync (lines 76-122 of the current file), share handler, phase tabs + auto-detection, `compare_viewed` analytics, empty states, `SchoolSearchModal`, max-4-visible column scroll. Replace the metric-picker/chart/table body with the section stack: sticky school chip bar (mockup `.school-bar`) → `CompareAtAGlance``CompareOfsted``CompareAcademics``CompareAdmissions``CompareCommunity``TrendsExplorer`. The page-level `metric` URL param now initialises `TrendsExplorer`'s picker only.
- [ ] Top-of-page subtitle + sources footnote verbatim from the mockups (minus the "Mockup" banner), including the suppression rule sentence and provenance sentence.
- [ ] `npm test` full suite + `tsc` clean. Commit: `feat(compare): parent-first compare screen assembly`
---
### Task 8: e2e journeys (the promotion gate)
**Files:**
- Modify: `e2e/tests/journeys.spec.ts` (the two compare tests, lines ~141-215; extend, don't delete coverage)
- [ ] Update 'comparing two schools shows both side by side': after loading `/compare?urns=…` assert the new section headings (`At a glance`, `Ofsted inspection`, `How children do academically`, `Getting a place`, `Who goes there`, `Explore trends`), both school names in the sticky bar, at least one England-average tick label (`text=/England \d+%/`), and one provenance string `state-school average` somewhere (benchmarks row). Data-invariant style — no exact numbers (staging data shifts; use latest-year values only).
- [ ] Update the mobile test: 390px viewport, assert measure-first stacking (a `.measure`-card contains all selected school names within one card) and that the trends chart container scrolls (`overflow-x`).
- [ ] Add a report-card presence-agnostic assertion: the Ofsted section renders either a grade badge or "Report card" without an overall grade — i.e. never both an overall-grade badge AND report-card chips for the same school.
- [ ] Run against staging from the host if reachable (`cd e2e && BASE_URL=https://stx.schoolcompare.co.uk npx playwright test -g "compar"`) — staging still runs the OLD UI until this PR merges, so expect failures locally; the authoritative run is the Stage pipeline post-merge. Still commit only after jest+tsc are green.
- [ ] Commit: `test(e2e): compare journeys for the parent-first redesign`
---
### Task 9: PR + post-merge verification
- [ ] Full gates: `cd nextjs-app && npm test && npx tsc --noEmit`.
- [ ] Push; open PR via Gitea API (credential-helper basic auth). PR body: before/after summary, link to mockups + spec §4/§8, the copy-verbatim rule, the fixed third-series bug, deploy note (needs PR #34's API on the same environment — merge order: #34 first), and that the e2e suite is the staging gate.
- [ ] Post-merge: watch the Stage pipeline — its e2e run against staging is the real verification. Then the human tests staging and promotes (two-stage model). Update memory: compare redesign shipped to staging.
---
## Out of scope
- IDACI / attendance / gender-absence / finance (post-v1, spec §4).
- Backend changes of any kind (PR #34 must merge first).
- Chart palette overhaul beyond the England-line addition (`CHART_COLORS` swap to the validated trio is a candidate follow-up, flagged not included — it affects every chart in the app).
@@ -0,0 +1,275 @@
# Staged Production Promotion (Manual Gate) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Merging a PR deploys to staging only; production deployment requires a second, explicit human approval after manual testing on staging.
**Architecture:** Split the existing single `deploy.yml` pipeline in two. The push-to-main workflow keeps build → staging deploy → e2e gate and **stops there**. A new `promote.yml` runs only on `workflow_dispatch` (the "Run workflow" button in Gitea's Actions UI, supported on this server — Gitea 1.26.4): it verifies the chosen commit passed the staging e2e gate, retags its `:sha-*` images to `:prod` (keeping `:prod-previous` for rollback), and triggers the Portainer prod webhook. Promotion granularity is a main-branch commit: staging always runs the latest main, so you approve a *state of main*, not an individual PR.
**Tech Stack:** Gitea Actions (1.26.4), Docker buildx imagetools, Portainer webhooks, Gitea commit-status API.
## Global Constraints
- **Never push to `main` directly** — this change itself goes through a PR (`chore/staged-prod-promotion` branch).
- Existing image tagging scheme is unchanged: `type=sha` (e.g. `sha-6f925ab`) + `:staging`; promotion still retags `:sha-*``:prod` with `:prod-previous` kept as the rollback pointer.
- The e2e journeys remain a **hard gate before human testing** (a red staging never reaches the promote button) and the promote workflow must refuse to promote a commit whose staging e2e did not succeed.
- Secrets already exist and are reused: `REGISTRY_TOKEN` (also a Gitea API token), `PORTAINER_STAGING_WEBHOOK`, `PORTAINER_PROD_WEBHOOK`, `STAGING_BASE_URL`, `PROD_BASE_URL`.
- Staging quirk (memory): external `/api` is broken at the staging proxy — manual API testing happens from the host, not through stx.schoolcompare.co.uk; note it in the runbook, don't try to fix it in this plan.
## Considered approaches (context for the reviewer)
1. **Manual `workflow_dispatch` promote workflow (chosen).** Native on Gitea 1.26; the second approval is clicking "Run workflow" (or one API call) after testing staging. Least machinery, auditable via the Actions run history.
2. *Tag-driven promotion* (`push: tags: promote-*`): works on any Gitea version; approval = pushing a tag. Slightly more scriptable, less discoverable; kept as documented fallback only.
3. *GitOps `production` branch + promotion PR:* approval literally reuses the PR-review UI, but adds a second long-lived branch to keep in sync — too much ceremony for a solo project. Rejected.
---
### Task 0: Branch
- [ ] `git checkout main && git pull && git checkout -b chore/staged-prod-promotion`
---
### Task 1: Stop the push-to-main workflow after the e2e gate
**Files:**
- Modify: `.gitea/workflows/deploy.yml`
**Interfaces:**
- Produces: images tagged `:sha-<short>` + `:staging` (unchanged), a green `E2E Journeys against Staging` commit status that Task 2's promote workflow checks by name. **Do not rename the `e2e-staging` job's `name:` without updating Task 2's status check.**
- [ ] **Step 1: Remove the auto-promotion**
In `.gitea/workflows/deploy.yml`:
1. Change line 1 to: `name: Stage (build -> staging -> E2E gate)`
2. Delete the entire `promote-prod` job (lines 196240 in the current file: from ` promote-prod:` to the end of the file).
3. Leave `build-*`, `deploy-staging`, and `e2e-staging` untouched.
- [ ] **Step 2: Sanity-check the YAML**
Run: `python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/deploy.yml')); print('yaml ok')"`
Expected: `yaml ok`
- [ ] **Step 3: Commit**
```bash
git add .gitea/workflows/deploy.yml
git commit -m "ci: stop deploy pipeline at staging; production promotion becomes manual"
```
---
### Task 2: Manual promote workflow
**Files:**
- Create: `.gitea/workflows/promote.yml`
**Interfaces:**
- Consumes: `:sha-<short>` images built by deploy.yml; the `E2E Journeys against Staging` commit status.
- Produces: `:prod` and `:prod-previous` tags; prod stack update.
- [ ] **Step 1: Write the workflow**
```yaml
name: Promote to Production (manual)
on:
workflow_dispatch:
inputs:
sha:
description: >-
Commit SHA on main to promote (full or >=7 chars).
Leave empty to promote the latest main commit.
required: false
default: ""
env:
REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs:
promote-prod:
name: Promote approved commit to Production
runs-on: ubuntu-latest
steps:
- name: Resolve target SHA
id: resolve
run: |
SHA_INPUT="${{ gitea.event.inputs.sha }}"
if [ -z "$SHA_INPUT" ]; then
SHA_INPUT="${{ gitea.sha }}"
fi
# Normalise to the full sha via the API so short inputs work
FULL_SHA=$(curl -fsS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/git/commits/${SHA_INPUT}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['sha'])")
SHORT_SHA="sha-$(echo "$FULL_SHA" | cut -c1-7)"
echo "full=$FULL_SHA" >> "$GITHUB_OUTPUT"
echo "short=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
- name: Verify the staging E2E gate passed for this commit
run: |
STATUS_JSON=$(curl -fsS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
echo "$STATUS_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
ok = [s for s in d.get('statuses', [])
if 'E2E Journeys against Staging' in s.get('context', '')
and s.get('status') == 'success']
if not ok:
print('REFUSED: no successful \"E2E Journeys against Staging\" status on this commit.')
print('Contexts found:', [s.get('context') for s in d.get('statuses', [])])
sys.exit(1)
print('E2E gate verified green for this commit.')
"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Retag approved images as prod (keeping rollback pointer)
run: |
SHORT_SHA="${{ steps.resolve.outputs.short }}"
for IMAGE in \
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
done
- name: Trigger production stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
- name: Wait for production to become healthy
run: |
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
echo "Production is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Production did not become healthy in time" >&2
exit 1
env:
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
```
Implementation notes for the engineer:
- Gitea Actions uses the GitHub-compatible `$GITHUB_OUTPUT` file for step outputs; if the runner image doesn't populate it, fall back to `$GITEA_OUTPUT` (check the runner's docs/output at first run).
- The retag step is copied verbatim from the old `promote-prod` job except the SHA comes from the resolved input instead of `gitea.sha` — behaviour for the default (empty input on latest main) is identical to before.
- If `docker buildx imagetools create` fails with "not found" for `${IMAGE}:${SHORT_SHA}`, the chosen commit predates the registry's retention or never built — the error message is the desired behaviour (refuse loudly).
- [ ] **Step 2: YAML sanity check**
Run: `python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/promote.yml')); print('yaml ok')"`
Expected: `yaml ok`
- [ ] **Step 3: Commit**
```bash
git add .gitea/workflows/promote.yml
git commit -m "ci: manual production promotion workflow with e2e-gate verification"
```
---
### Task 3: Documentation — deploy model + runbook
**Files:**
- Modify: `docs/DEPLOY.md`
- Modify: `claude.md` (the SDLC section)
- [ ] **Step 1: Rewrite the flow description in `docs/DEPLOY.md`**
Replace the staging→prod description with the new model (adapt to the file's existing structure; the substance to convey):
```markdown
## Deploy model
1. **PR → main (first approval).** Branch-protected merge; PR checks
(typecheck, tests, builds, AI review) must pass.
2. **Merge → staging (automatic).** Images are built once and tagged
`sha-<short>` + `staging`; the staging stack updates; Playwright
journeys in `e2e/` run against staging. A red e2e run means staging
is not fit for testing — fix forward before considering promotion.
3. **Manual testing on staging.** stx.schoolcompare.co.uk. Note:
external `/api` is broken at the staging proxy — exercise API
endpoints from the host.
4. **Promote → production (second approval).** Actions → "Promote to
Production (manual)" → Run workflow. Leave the SHA empty to promote
the latest main, or paste a specific commit SHA. The workflow
refuses commits whose staging e2e gate is not green, retags the
images `:prod` (keeping `:prod-previous`), and updates the prod
stack.
### Promotion granularity
Staging always runs the latest `main`. Promoting approves a *state of
main*, not a single PR — if two PRs merged since the last promotion,
they ship together. Test staging accordingly.
### Rollback
Re-run "Promote to Production (manual)" with the SHA of the last good
commit (or retag manually: `docker buildx imagetools create -t
<image>:prod <image>:prod-previous` for each of the three images, then
POST the prod Portainer webhook).
```
- [ ] **Step 2: Update the SDLC bullet in `claude.md`**
Replace the sentence "Merging to `main` deploys automatically: … retagged `:prod` and rolled out to production." with:
```markdown
- Merging to `main` deploys automatically **to staging only**: images
are built once, deployed to the staging Portainer stack, and verified
by the Playwright journeys in `e2e/`. Production is a second, manual
approval: the "Promote to Production (manual)" workflow in Gitea
Actions, run after testing the feature on staging. It refuses commits
whose staging e2e gate isn't green.
```
- [ ] **Step 3: Commit**
```bash
git add docs/DEPLOY.md claude.md
git commit -m "docs: two-stage deploy model (staging auto, production manual)"
```
---
### Task 4: PR + live validation
- [ ] **Step 1: Push and open the PR** (Gitea API with credential-helper basic auth, as usual). PR body: the new model in three lines, the rollback recipe, and a warning that between merging this PR and its first promotion run, production receives no deployments (expected).
- [ ] **Step 2: Validate after merge (human-in-the-loop):**
1. Merge this PR → confirm the `Stage (build -> staging -> E2E gate)` run goes green and **no** production deployment happens (prod image digest unchanged: `docker buildx imagetools inspect <image>:prod` before/after, or check the Portainer prod stack's last-update time).
2. Test something trivial on staging.
3. Run "Promote to Production (manual)" with the SHA empty → confirm e2e verification passes, retag happens, prod becomes healthy.
4. Negative test: run the promote workflow with a garbage SHA (e.g. `deadbeef1`) → confirm it fails at resolve/verify without touching `:prod`.
- [ ] **Step 3: Update the ledger/memory** with the new deploy model so future sessions stop assuming auto-promotion.
---
## Out of scope / future options
- Notifications when staging is ready for testing (Gitea can email on workflow completion; a webhook to ntfy/Matrix could be added later).
- Restricting who can run the promote workflow: Gitea 1.26 runs `workflow_dispatch` with the permissions of the dispatching user; for a solo repo this is already effectively restricted.
- The tag-driven fallback (`on: push: tags: promote-*`) if `workflow_dispatch` ever proves unreliable on the runner.
@@ -0,0 +1,931 @@
# Compare Screen Must-Fix (Final Expert Review) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the five promotion-blocking findings from the expert's final staging review: (1) blank all-secondary compare view, (2) report cards dated with pre-Nov-2025 legacy inspection dates, (3) FSM chip benchmarked against the wrong measure, (4) KS4 "national averages" that are dataset means presented as official DfE figures, (5) factually wrong "DfE didn't publish 2021/22" footnote.
**Architecture:** One branch/PR touching all three layers. Pipeline: a new tap field carries the report-card inspection's own date; a new EES stream ingests official KS4 national headlines; a new census-benchmarks mart replaces junk KS2-derived context medians. Backend: serialize the new fields, stop mislabelling computed KS4 means as official. Frontend: fix the phase-detection effect that leaves all-secondary comparisons stuck on an empty "primary" tab, date report cards correctly, drop the FSM→disadvantaged fallback, fix the footnote copy.
**Tech Stack:** Meltano/Singer taps (Python), dbt-postgres, FastAPI/SQLAlchemy/pandas, Next.js app router + Jest, Playwright e2e.
## Global Constraints
- Never push to `main`; work on branch `fix/compare-final-review-mustfix`, open a PR. Never trigger the "Promote to Production (manual)" workflow — promotion is exclusively the human's call.
- User-facing behaviour changes must extend the `e2e/` journeys in the same PR (they gate staging fitness and promotability).
- All user-facing copy on the compare screen comes verbatim from `docs/superpowers/specs/mockups/compare-desktop.html` / `compare-mobile.html` — except where this plan explicitly changes copy to fix a factual error (Task 6); the spec/mockup gets the same wording in the same commit.
- Benchmark provenance house style: official figures = "England average"; computed figures = "state-school average (computed from our dataset)".
- A report card must NEVER be displayed with a pre-November-2025 date. Report cards exist only from November 2025.
- Backend tests: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q` (repo root; there is no local pytest).
- dbt: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` (never bare `dbt` — the Fusion binary shadows dbt-postgres).
- Frontend: `cd nextjs-app && npx tsc --noEmit && npm test` (run tsc un-piped so exit codes are not masked).
- Do NOT start a local server to test the application (CLAUDE.md).
- Commits end with the Claude Code `Co-Authored-By` + `Claude-Session` trailers used on this branch's history.
## Root-Cause Evidence (verified 2026-07-16, do not re-derive)
- **Finding 1:** `nextjs-app/components/ComparisonView.tsx:164-176` — the auto-phase effect returns early when `selectedSchools.length === 0` (basket hydrates a beat after mount) and its dep array is only `[comparisonData]`, so it never re-fires; `comparePhase` stays `'primary'`, `activeSchools` is empty, the page renders "No primary schools in your comparison" (a11y snapshot confirmed). No console errors — not a crash.
- **Finding 2:** In the Ofsted MI CSV (`Management_information_-_state-funded_schools_-_latest_inspections_as_at_31_May_2026.csv`) the report-card grade columns (cols 3855, "Safeguarding standards", "Inclusion", …) belong to the **latest full inspection** block whose date is col 30 "Inspection start date" (Barclay 138690: `03/02/2026`). The tap's `inspection_date` COLUMN_PRIORITY matches col 60 "Inspection start date of latest OEIF graded inspection" first (the *legacy* date; NULL for Barclay, so stg coalesces to the 2021 *ungraded* date). The rc data is **real Ofsted data, not fabricated** — it is mis-dated. Also `discover_csv_url()` returns `matches[0]` = the oldest (2017) link on the GOV.UK page; staging works only because `mi_url` is set in the environment. Staging raw is stale for at least Watford Grammar 136276 (staging shows rc grades; the current MI file has all rc columns NULL for it) — a fresh extract fixes that via upsert on `(urn, inspection_date)`.
- **Finding 3:** `nextjs-app/components/compare/CompareCommunity.tsx:36``bench?.fsm_pct ?? bench?.disadvantaged_pct` falls back across definitions. `benchmarks.primary.fsm_pct` is null because `compute_benchmarks` (backend/data_loader.py:528) medians the *performance* df, which has no `fsm_pct` (school FSM comes from `census.fsm_pct` = `fact_pupil_characteristics`). `disadvantaged_pct` / `eal_pct` are KS2-only columns, so the "secondary" medians (50.0 / 10.0) are computed over the few all-through schools' KS2 rows — junk.
- **Finding 4:** `fact_ks4_national_averages.sql` computes unweighted school means (A8 38.94 vs official 46.0; national P8 0.27, impossible). Official series exists on EES: data-set `1b649e16-01e8-435b-a814-56be2faf9054` ("National characteristics summary data", KS4 performance publication), CSV endpoint same pattern as the KS2 national stream, national level, 2018/19→2024/25, `establishment_type_group = 'All state-funded'`, `breakdown_topic = 'Total'`, `breakdown = 'Total'`. Verified values: 2024/25 A8 46.0, P8 `z` (not published — no KS2 baseline for that cohort), EM 9-5 45.4%, EBacc entry 40.5%. It has **no** `gcse_91_percent` column.
- **Finding 5:** `ComparisonChart.tsx:245-246` claims "DfE didn't publish school-level figures for 2021/22". False — DfE published school-level KS2 for 2021/22 in Dec 2022; spec §8.1 itself lists loading it as a pipeline task. The honest claim is that the figures aren't in our dataset.
---
### Task 1: All-secondary comparison renders (phase-detection fix)
**Files:**
- Modify: `nextjs-app/components/ComparisonView.tsx:176`
- Create: `nextjs-app/__tests__/components/ComparisonView.phase.test.tsx`
- Modify: `e2e/tests/journeys.spec.ts` (add helper + journey after the existing `twoPrimaryUrns` helper / primary compare journey)
**Interfaces:**
- Consumes: existing `ComparisonView` props (`initialData`, `initialUrns`, `metrics`, `selectedMetric`), `ComparisonProvider`.
- Produces: no API changes; the auto-phase effect re-runs when the basket hydrates.
- [ ] **Step 1: Write the failing Jest test**
Create `nextjs-app/__tests__/components/ComparisonView.phase.test.tsx` (mirrors the mock setup of `ComparisonView.refresh.test.tsx`):
```tsx
/**
* Regression: an all-secondary comparison must render the secondary sections.
*
* The basket hydrates from the URL a beat after mount, so the auto-phase
* effect must re-run once selectedSchools arrives — with deps of only
* [comparisonData] it fired once against an empty basket, bailed, and the
* page stayed on an empty "primary" tab ("No primary schools in your
* comparison") even though all schools were secondary.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { ComparisonView } from '@/components/ComparisonView';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import type { ComparisonData, School } from '@/lib/types';
const fetchComparison = jest.fn();
jest.mock('@/lib/api', () => ({
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
function secondarySchool(urn: number, name: string): School {
return {
urn,
school_name: name,
local_authority: 'Testshire',
school_type: 'Academy converter',
attainment_8_score: 55,
phase: 'Secondary',
} as School;
}
function data(urn: number, name: string): ComparisonData {
return {
school_info: secondarySchool(urn, name),
yearly_data: [{ year: 202425, attainment_8_score: 55 }] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
const INITIAL_DATA = {
'300': data(300, 'Gamma High'),
'400': data(400, 'Delta Academy'),
};
test('an all-secondary comparison renders the sections, not an empty primary tab', async () => {
render(
<ComparisonProvider>
<ComparisonView
initialData={INITIAL_DATA}
initialNationalAverages={{
year: 202425,
primary: {},
secondary: { attainment_8_score: 46 },
by_year: [],
}}
initialBenchmarks={undefined}
initialUrns={[300, 400]}
metrics={[]}
selectedMetric="attainment_8_score"
/>
</ComparisonProvider>,
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
});
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
expect(fetchComparison).not.toHaveBeenCalled();
});
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd nextjs-app && npx jest __tests__/components/ComparisonView.phase.test.tsx`
Expected: FAIL — "No primary schools in your comparison" is rendered / "At a glance" never appears.
- [ ] **Step 3: Fix the effect dependencies**
In `nextjs-app/components/ComparisonView.tsx`, the auto-phase effect currently ends:
```tsx
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
```
Change to:
```tsx
// selectedSchools is a dep because the basket hydrates after mount: the
// first run sees an empty basket and bails, so it must re-fire when the
// schools arrive. primarySchools/secondarySchools/metrics/selectedMetric
// are intentionally omitted (derived or would cause loops).
}, [comparisonData, selectedSchools]); // eslint-disable-line react-hooks/exhaustive-deps
```
(`phaseLockedByUser` still suppresses re-detection after a manual tab click; re-running with unchanged inputs sets the same state, which React treats as a no-op.)
- [ ] **Step 4: Run the new test and the existing suite**
Run: `cd nextjs-app && npx tsc --noEmit && npm test`
Expected: PASS, including `ComparisonView.refresh.test.tsx` (the refresh regression must stay green).
- [ ] **Step 5: Add the e2e secondary journey**
In `e2e/tests/journeys.spec.ts`, add below `twoPrimaryUrns`:
```ts
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
const res = await page.request.get('/api/schools?search=school&per_page=100');
expect(res.ok()).toBeTruthy();
const body = await res.json();
const urns: string[] = (body.schools ?? [])
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
s.phase === 'Secondary' && s.attainment_8_score != null,
)
.map((s: { urn: number }) => String(s.urn));
expect(urns.length).toBeGreaterThanOrEqual(2);
return [urns[0], urns[1]];
}
```
(If `/api/schools` list rows lack `attainment_8_score`, filter on `s.phase === 'Secondary'` only — check the response first.) Then add a journey test next to the primary compare journey:
```ts
test('comparing two secondary schools renders the secondary sections', async ({ page }) => {
const [urn0, urn1] = await twoSecondaryUrns(page);
await page.goto(`/compare?urns=${urn0},${urn1}`);
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
// The parent-first sections must render — this page was completely blank
// for all-secondary baskets (expert review must-fix #1).
await expect(page.getByRole('heading', { name: 'At a glance' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Ofsted inspection' })).toBeVisible();
// A KS4 measure proves the secondary academics variant rendered.
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
});
```
- [ ] **Step 6: Commit**
```bash
git add nextjs-app/components/ComparisonView.tsx nextjs-app/__tests__/components/ComparisonView.phase.test.tsx e2e/tests/journeys.spec.ts
git commit -m "fix(compare): render all-secondary comparisons — re-run phase detection after basket hydration"
```
---
### Task 2: Report-card inspection date through the pipeline
**Files:**
- Modify: `pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py` (COLUMN_PRIORITY, schema, `discover_csv_url`)
- Modify: `pipeline/transform/models/staging/stg_ofsted_inspections.sql`
- Modify: `pipeline/transform/models/intermediate/int_ofsted_latest.sql`
- Modify: `pipeline/transform/models/marts/fact_ofsted_inspection.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (add column doc if other fact_ofsted columns are documented there)
**Interfaces:**
- Consumes: MI CSV column `Inspection start date` (the latest **full** inspection = the report-card inspection in the renewed framework; NULL when a school's only inspections are legacy OEIF/ungraded — verified for Watford Grammar).
- Produces: `marts.fact_ofsted_inspection.rc_inspection_date` (DATE, null unless the row carries report-card grades). Task 3 depends on this exact column name.
- [ ] **Step 1: Add the tap field**
In `tap.py` COLUMN_PRIORITY, after the `rc_sixth_form` entry, add:
```python
# Date of the latest FULL inspection — in the renewed framework this is
# the report-card inspection's own start date (col "Inspection start
# date"), distinct from the legacy OEIF graded/ungraded dates above.
"rc_inspection_date": ["Inspection start date"],
```
and in the stream schema, next to the other rc properties:
```python
th.Property("rc_inspection_date", th.StringType),
```
Note: `inspection_date`'s own priority list also contains `"Inspection start date"` as a lower-priority candidate — that stays; in renewed-framework files the higher-priority OEIF column exists so they map to different columns, and in legacy files both map to the same column but rc grades are absent, and staging nulls `rc_inspection_date` in that case (Step 3).
- [ ] **Step 2: Fix `discover_csv_url` to pick the newest file, not `matches[0]`**
The GOV.UK page lists 2017 files first; `matches[0]` is a 2017 CSV. Replace the body of `discover_csv_url()` to date-sort the `latest_inspections_as_at` links, mirroring `discover_independent_csv_url`:
```python
def discover_csv_url() -> str | None:
"""Scrape GOV.UK page to find the latest MI CSV download link.
The page lists a decade of monthly files, oldest first — take the
newest 'latest inspections as at <date>' link by parsing its date,
never matches[0].
"""
resp = requests.get(GOV_UK_PAGE, timeout=30)
resp.raise_for_status()
csv_links = re.findall(
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
resp.text,
)
months = {
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12,
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'jun': 6,
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
}
parsed_links = []
for link in csv_links:
normalized = link.lower().replace('-', '_')
if 'latest_inspections_as_at' not in normalized:
continue
match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized)
if match:
day, month_str, year = match.groups()
month = months.get(month_str)
if month:
try:
parsed_links.append((datetime(int(year), month, int(day)), link))
except ValueError:
continue
parsed_links.sort(reverse=True)
if parsed_links:
return parsed_links[0][1]
if csv_links:
return csv_links[-1]
matches = re.findall(
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
resp.text,
)
return matches[0] if matches else None
```
(`mi_url` config still wins when set — `self.config.get("mi_url") or discover_csv_url()` is unchanged.)
- [ ] **Step 3: Parse and guard the date in staging**
In `stg_ofsted_inspections.sql`, inside the `renamed` CTE after the `rc_sixth_form` line, add:
```sql
-- Start date of the latest FULL inspection (the report-card
-- inspection in the renewed framework). Guarded below: only kept
-- when the row actually carries report-card grades, because in
-- legacy-format files this column is the legacy inspection date.
to_date(nullif(trim(rc_inspection_date), 'NULL'), 'DD/MM/YYYY') as rc_inspection_date_raw,
```
and replace the final select:
```sql
select
*,
case
when rc_safeguarding_met is not null
or rc_inclusion is not null
or rc_curriculum_teaching is not null
or rc_achievement is not null
or rc_attendance_behaviour is not null
or rc_personal_development is not null
or rc_leadership_governance is not null
then rc_inspection_date_raw
end as rc_inspection_date
from renamed
where inspection_date is not null
```
- [ ] **Step 4: Propagate through int + mart**
Add `rc_inspection_date,` to the explicit column lists of `int_ofsted_latest.sql` and `fact_ofsted_inspection.sql` (after `rc_sixth_form`). Do NOT propagate `rc_inspection_date_raw`.
- [ ] **Step 5: Parse-check dbt**
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .`
Expected: parse OK, no compilation errors.
- [ ] **Step 6: Commit**
```bash
git add pipeline/plugins/extractors/tap-uk-ofsted pipeline/transform/models
git commit -m "feat(pipeline): carry the report-card inspection's own date; pick newest MI file in discovery"
```
---
### Task 3: Report-card date in the API and UI
**Files:**
- Modify: `backend/models.py` (FactOfstedInspection)
- Modify: `backend/data_loader.py` (`_ofsted_block`)
- Test: `backend/tests/test_supplementary_enrichment.py` (extend the existing `_ofsted_block` tests)
- Modify: `nextjs-app/lib/types.ts` (OfstedInspection)
- Modify: `nextjs-app/components/compare/CompareOfsted.tsx` ("Inspected" measure)
- Test: `nextjs-app/__tests__/components/CompareOfsted.test.tsx`
**Interfaces:**
- Consumes: `marts.fact_ofsted_inspection.rc_inspection_date` (Task 2).
- Produces: API `ofsted.rc_inspection_date: string | null` (ISO date). UI rule: report-card displays are dated with `rc_inspection_date` only; when null, show "—" (never the legacy date).
- [ ] **Step 1: Failing backend test**
In `backend/tests/test_supplementary_enrichment.py`, alongside the existing `_ofsted_block` tests, add (reuse the file's existing fake-row helper/style):
```python
def test_ofsted_block_carries_rc_inspection_date():
o = _fake_ofsted_row( # use this file's existing fake/stub construction
overall_effectiveness=None,
ungraded_grade=2,
rc_achievement=1,
rc_inspection_date=date(2026, 2, 3),
inspection_date=date(2021, 10, 7),
)
block = _ofsted_block(o, 138690)
assert block["rc_inspection_date"] == "2026-02-03"
# The legacy inspection date is still present, unchanged.
assert block["inspection_date"] == "2021-10-07"
def test_ofsted_block_rc_inspection_date_none_when_absent():
o = _fake_ofsted_row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
block = _ofsted_block(o, 136276)
assert block["rc_inspection_date"] is None
```
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_supplementary_enrichment.py -q`
Expected: FAIL (KeyError / AttributeError on `rc_inspection_date`).
- [ ] **Step 2: Backend implementation**
`backend/models.py`, in `FactOfstedInspection` after `rc_sixth_form`:
```python
# Start date of the report-card inspection itself (renewed framework,
# Nov 2025+). Null for rows without report-card grades.
rc_inspection_date = Column(Date)
```
`backend/data_loader.py` `_ofsted_block`, after the `"inspection_date"` entry:
```python
"rc_inspection_date": (
o.rc_inspection_date.isoformat()
if getattr(o, "rc_inspection_date", None)
else None
),
```
(`getattr` default keeps old test stubs working.) Run the backend suite; expected: PASS.
- [ ] **Step 3: Failing frontend test**
`nextjs-app/lib/types.ts`, in `OfstedInspection`, after `inspection_date`:
```ts
/** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
rc_inspection_date?: string | null;
```
In `nextjs-app/__tests__/components/CompareOfsted.test.tsx`, add to the existing suite (reusing its fixture style):
```tsx
it('dates a report card with the report-card inspection date, never the legacy date', () => {
const ofsted = reportCardOfsted({
inspection_date: '2021-10-07',
rc_inspection_date: '2026-02-03',
});
render(<CompareOfsted schools={[schoolFixture]} data={{ [String(schoolFixture.urn)]: { ...dataFixture, ofsted } }} />);
expect(screen.getByText(/3 Feb 2026/)).toBeInTheDocument();
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
expect(screen.queryByText('4+ years ago')).toBeNull();
});
it('shows an em dash when a report card has no rc_inspection_date yet', () => {
const ofsted = reportCardOfsted({ inspection_date: '2021-10-07', rc_inspection_date: null });
render(<CompareOfsted schools={[schoolFixture]} data={{ [String(schoolFixture.urn)]: { ...dataFixture, ofsted } }} />);
expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
});
```
(`reportCardOfsted` = the file's existing report-card fixture builder, or build inline matching its other tests.) Run just this file; expected: FAIL.
- [ ] **Step 4: Frontend implementation**
In `CompareOfsted.tsx`, replace the body of the "Inspected" measure's map:
```tsx
{schools.map((school, i) => {
const ofsted = data[String(school.urn)]?.ofsted;
// A report card is dated by its OWN inspection date. The legacy
// inspection_date belongs to an older inspection and must never
// be shown against a report card (report cards exist only from
// Nov 2025).
const dateIso =
displays[i].kind === 'report_card'
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
const age = yearsSince(dateIso);
return (
<Cell key={school.urn} school={school} index={i}>
{formatInspectionDate(dateIso)}{' '}
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
</Cell>
);
})}
```
- [ ] **Step 5: Run frontend checks**
Run: `cd nextjs-app && npx tsc --noEmit && npm test`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add backend/models.py backend/data_loader.py backend/tests nextjs-app/lib/types.ts nextjs-app/components/compare/CompareOfsted.tsx nextjs-app/__tests__/components/CompareOfsted.test.tsx
git commit -m "fix(compare): date report cards with their own inspection date, never the legacy one"
```
---
### Task 4: Census-based context benchmarks; kill the FSM fallback
**Files:**
- Create: `pipeline/transform/models/marts/fact_census_benchmarks.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml`
- Modify: `backend/models.py` (new `CensusBenchmark` model)
- Modify: `backend/data_loader.py` (`compute_benchmarks`)
- Modify: `backend/app.py` (compare endpoint call site, only if the signature change requires it)
- Test: `backend/tests/test_benchmarks.py`
- Modify: `nextjs-app/components/compare/CompareCommunity.tsx:36`
- Test: `nextjs-app/__tests__/lib/compareLogic.test.ts` or the community section's existing test home (add a fallback-removal test where the FSM chip logic is tested today)
**Interfaces:**
- Consumes: `marts.fact_pupil_characteristics` (urn, year, phase_type_grouping, total_pupils, fsm_pct, eal_pct).
- Produces: `marts.fact_census_benchmarks` — one row per phase (`'primary'`/`'secondary'`), columns `phase, year, fsm_pct, eal_pct, median_pupils`. `fsm_pct`/`eal_pct` are **pupil-weighted means** (so they approximate the national pupil-level rate, answering the expert's objection to school-median anchors). API `benchmarks.{primary,secondary}` keeps its existing keys; `fsm_pct`/`eal_pct`/`median_pupils` now come from this mart; `disadvantaged_pct` becomes primary-only (the KS2-column median was junk for secondary).
- [ ] **Step 1: dbt mart**
Create `pipeline/transform/models/marts/fact_census_benchmarks.sql`:
```sql
{{ config(materialized='table') }}
-- Mart: state-school context benchmarks from the pupil census — one row per
-- phase, latest census year. Computed at import time (never per request).
-- fsm_pct / eal_pct are pupil-weighted means, i.e. "what % of pupils", not
-- "the median school" — this matches how DfE quotes national FSM/EAL rates.
-- Consumers must label these "state-school average (computed from our
-- dataset)" (spec §8.6), never "England average".
with latest as (
select max(year) as year from {{ ref('fact_pupil_characteristics') }}
),
classified as (
select
case
when p.phase_type_grouping ilike '%primary%' then 'primary'
when p.phase_type_grouping ilike '%secondary%' then 'secondary'
end as phase,
p.total_pupils,
p.fsm_pct,
p.eal_pct,
l.year
from {{ ref('fact_pupil_characteristics') }} p
join latest l on p.year = l.year
where p.total_pupils is not null and p.total_pupils > 0
)
select
phase,
max(year) as year,
round((sum(fsm_pct * total_pupils) filter (where fsm_pct is not null)
/ nullif(sum(total_pupils) filter (where fsm_pct is not null), 0))::numeric, 1) as fsm_pct,
round((sum(eal_pct * total_pupils) filter (where eal_pct is not null)
/ nullif(sum(total_pupils) filter (where eal_pct is not null), 0))::numeric, 1) as eal_pct,
round(percentile_cont(0.5) within group (order by total_pupils))::integer as median_pupils
from classified
where phase is not null
group by phase
```
Add a `fact_census_benchmarks` entry to `_marts_schema.yml` in the file's existing style (name + description; column tests only if sibling marts have them).
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
- [ ] **Step 2: Failing backend test**
In `backend/tests/test_benchmarks.py` add:
```python
def test_benchmarks_use_census_mart_for_context(monkeypatch):
census = {
"primary": {"year": 202425, "fsm_pct": 25.3, "eal_pct": 21.8, "median_pupils": 240},
"secondary": {"year": 202425, "fsm_pct": 24.1, "eal_pct": 18.9, "median_pupils": 980},
}
result = compute_benchmarks(_sample_df(), census_benchmarks=census)
assert result["primary"]["fsm_pct"] == 25.3
assert result["secondary"]["eal_pct"] == 18.9
assert result["secondary"]["median_pupils"] == 980
# KS2-only columns must not produce a fake secondary disadvantaged anchor.
assert result["secondary"]["disadvantaged_pct"] is None
def test_benchmarks_context_none_when_mart_missing():
result = compute_benchmarks(_sample_df(), census_benchmarks=None)
assert result["primary"]["fsm_pct"] is None # never silently fall back
```
(`_sample_df()` = this file's existing dataframe fixture.) Run the file; expected: FAIL (unexpected keyword `census_benchmarks`).
- [ ] **Step 3: Backend implementation**
`backend/models.py` (next to the national-average models):
```python
class CensusBenchmark(Base):
"""State-school context benchmarks from the pupil census — one row per phase."""
__tablename__ = "fact_census_benchmarks"
__table_args__ = MARTS
phase = Column(String(20), primary_key=True)
year = Column(Integer)
fsm_pct = Column(Float) # pupil-weighted mean
eal_pct = Column(Float) # pupil-weighted mean
median_pupils = Column(Integer)
```
`backend/data_loader.py` — change the signature and `_block`:
```python
def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None) -> dict:
```
Inside, keep `_median` and `_weighted_disadvantaged` as-is, and replace `_block` with:
```python
def _block(sub, phase, with_disadvantaged):
census = (census_benchmarks or {}).get(phase) or {}
block = {
# Context measures come from the census mart (pupil-weighted):
# the performance df has no fsm_pct, and its eal/disadvantaged
# columns are KS2-only — medianing them for "secondary" produced
# junk anchors from the handful of all-through schools.
"eal_pct": census.get("eal_pct"),
"sen_support_pct": _median(sub, "sen_support_pct"),
"disadvantaged_pct": _median(sub, "disadvantaged_pct") if with_disadvantaged else None,
"fsm_pct": census.get("fsm_pct"),
"median_pupils": census.get("median_pupils"),
}
if with_disadvantaged:
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
return block
```
and the return:
```python
return {
"source": "state-school average (computed from our dataset)",
"year": int(latest_year),
"primary": _block(prim, "primary", with_disadvantaged=True),
"secondary": _block(sec, "secondary", with_disadvantaged=False),
}
```
In `backend/app.py`'s compare endpoint, load the mart and pass it (same defensive style as the national-averages queries):
```python
census_benchmarks = None
try:
rows = db.query(CensusBenchmark).all()
if rows:
census_benchmarks = {
r.phase: {
"year": r.year,
"fsm_pct": r.fsm_pct,
"eal_pct": r.eal_pct,
"median_pupils": r.median_pupils,
}
for r in rows
}
except Exception:
db.rollback()
...
"benchmarks": compute_benchmarks(df, census_benchmarks=census_benchmarks),
```
(Import `CensusBenchmark`; use the endpoint's existing db session pattern.) Run the backend suite; expected: PASS (update any existing benchmark tests that asserted the old median-sourced fsm/eal values).
- [ ] **Step 4: Frontend — remove the cross-definition fallback**
`nextjs-app/components/compare/CompareCommunity.tsx:36`:
```tsx
const anchor = bench?.fsm_pct ?? null;
```
If the FSM chip has unit coverage, update/add the case: `anchor` null ⇒ no verdict chip rendered (bare value only). Run `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
- [ ] **Step 5: Commit**
```bash
git add pipeline/transform/models/marts backend/models.py backend/data_loader.py backend/app.py backend/tests/test_benchmarks.py nextjs-app/components/compare/CompareCommunity.tsx nextjs-app/__tests__
git commit -m "fix(compare): census-sourced FSM/EAL benchmarks; never fall back across measure definitions"
```
---
### Task 5: Official KS4 national averages
**Files:**
- Modify: `pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py` (new stream, registered in `discover_streams`)
- Create: `pipeline/transform/models/staging/stg_ees_ks4_national.sql`
- Modify: `pipeline/transform/models/staging/_stg_sources.yml` (add raw table `ees_ks4_national`)
- Modify: `pipeline/transform/models/marts/fact_ks4_national_averages.sql` (rewrite)
- Modify: `backend/models.py` (Ks4NationalAverage docstring), `backend/app.py` (`_national_averages_payload` — remove the computed fallback)
- Test: `backend/tests/test_national_averages_marts.py`
**Interfaces:**
- Consumes: EES data-catalogue CSV `https://explore-education-statistics.service.gov.uk/data-catalogue/data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv` (columns verified: `time_period, geographic_level, establishment_type_group, breakdown_topic, breakdown, attainment8_average, progress8_average, engmath_95_percent, engmath_94_percent, ebacc_entering_percent, ebacc_95_percent, ebacc_94_percent, ebacc_aps_average, …`).
- Produces: `marts.fact_ks4_national_averages` with the SAME columns as today (so `Ks4NationalAverage` needs no schema change), now holding official DfE figures; `gcse_grade_91_pct` is NULL (not in the official series — the England anchor for that measure disappears, which is correct: it was noise).
- [ ] **Step 1: Tap stream**
In `tap.py`, after the KS2 national stream, add:
```python
# ── KS4 National Headlines (national level only — one row per year) ──────────
# Dataset: "National characteristics summary data" (Key stage 4 performance).
# Official England state-funded headline measures, 2018/19 → latest.
# Suppressed values ('z', 'x') → NULL downstream. Progress 8 is legitimately
# absent in years with no KS2 baseline (e.g. 2024/25) — that is DfE policy,
# not missing data.
_KS4_NATIONAL_CSV_URL = (
"https://explore-education-statistics.service.gov.uk/data-catalogue/"
"data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv"
)
_KS4_NATIONAL_COL_MAP = {
"attainment8_average": "attainment_8_score",
"progress8_average": "progress_8_score",
"engmath_94_percent": "english_maths_standard_pass_pct",
"engmath_95_percent": "english_maths_strong_pass_pct",
"ebacc_entering_percent": "ebacc_entry_pct",
"ebacc_94_percent": "ebacc_standard_pass_pct",
"ebacc_95_percent": "ebacc_strong_pass_pct",
"ebacc_aps_average": "ebacc_avg_score",
}
class EESKs4NationalStream(Stream):
"""National KS4 headline averages — one row per academic year.
Filters to geographic_level == 'National', establishment_type_group ==
'All state-funded', breakdown_topic == 'Total', breakdown == 'Total'
so only the England-wide all-pupils row per year is emitted.
"""
name = "ees_ks4_national"
primary_keys = ["time_period"]
replication_key = None
schema = th.PropertiesList(
th.Property("time_period", th.StringType, required=True),
*[th.Property(out, th.StringType) for out in _KS4_NATIONAL_COL_MAP.values()],
).to_dict()
def get_records(self, context):
import pandas as pd
self.logger.info("Downloading KS4 national headlines: %s", _KS4_NATIONAL_CSV_URL)
resp = requests.get(_KS4_NATIONAL_CSV_URL, timeout=60)
resp.raise_for_status()
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
df.columns = [c.strip().lower() for c in df.columns]
for col, want in [
("geographic_level", "national"),
("establishment_type_group", "all state-funded"),
("breakdown_topic", "total"),
("breakdown", "total"),
]:
if col in df.columns:
df = df[df[col].str.strip().str.lower() == want]
self.logger.info("Emitting %d national KS4 rows", len(df))
for _, row in df.iterrows():
record = {"time_period": row.get("time_period", "").strip()}
for src, out in _KS4_NATIONAL_COL_MAP.items():
record[out] = row.get(src, "")
yield record
```
Register `EESKs4NationalStream(self)` in `discover_streams` next to the KS2 national stream.
- [ ] **Step 2: Raw source + staging model**
Add to `_stg_sources.yml` under the raw source, matching the `ees_ks2_national` entry's style:
```yaml
- name: ees_ks4_national
description: Official DfE KS4 national headline averages (EES data catalogue)
```
Create `pipeline/transform/models/staging/stg_ees_ks4_national.sql`:
```sql
{{ config(materialized='table') }}
-- Staging model: official DfE KS4 national headline averages — one row per
-- academic year (England, all state-funded, all pupils). Source: EES data
-- catalogue "National characteristics summary data". Suppressed values
-- ('z', 'x') are coerced to NULL by safe_numeric — Progress 8 is 'z' in
-- years with no KS2 baseline (e.g. 2024/25): legitimately unpublished.
select
cast(trim(time_period) as integer) as year,
{{ safe_numeric('attainment_8_score') }} as attainment_8_score,
{{ safe_numeric('progress_8_score') }} as progress_8_score,
{{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
{{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
{{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
{{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
{{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
{{ safe_numeric('ebacc_avg_score') }} as ebacc_avg_score
from {{ source('raw', 'ees_ks4_national') }}
where time_period ~ '^[0-9]+$'
```
- [ ] **Step 3: Rewrite the mart**
Replace the entire body of `fact_ks4_national_averages.sql`:
```sql
{{ config(materialized='table') }}
-- Mart: OFFICIAL DfE KS4 national headline averages — one row per academic
-- year (England, state-funded, all pupils), from the EES national dataset.
-- Replaces the previous unweighted school-level means, which were 715
-- points off every headline measure and produced an arithmetically
-- impossible national Progress 8. gcse_grade_91_pct has no official
-- national series and is NULL (schema kept for the API model).
select
year,
attainment_8_score,
progress_8_score,
english_maths_standard_pass_pct,
english_maths_strong_pass_pct,
ebacc_entry_pct,
ebacc_standard_pass_pct,
ebacc_strong_pass_pct,
ebacc_avg_score,
cast(null as double precision) as gcse_grade_91_pct
from {{ ref('stg_ees_ks4_national') }}
order by year
```
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
- [ ] **Step 4: Backend — official provenance, no computed fallback**
`backend/models.py`: change the `Ks4NationalAverage` docstring to `"""Official DfE KS4 national headline averages — one row per academic year."""`.
`backend/app.py` `_national_averages_payload`: delete the entire `if not any(secondary_by_year.values()):` fallback block (it computes dataset means that the UI footnote then labels official). Update the function docstring's KS4 sentence to: `official DfE KS4 figures (fact_ks4_national_averages). If the KS4 mart hasn't been built yet, the secondary series is empty — never a computed stand-in, because the UI labels these figures as official.`
Update `backend/tests/test_national_averages_marts.py`: the test that exercised the fallback now asserts the opposite —
```python
def test_ks4_secondary_empty_when_mart_missing(...):
# No computed stand-in: the UI labels national figures as official DfE
# data, so an empty mart must yield an empty secondary series.
payload = _national_averages_payload(df)
assert all(not e["secondary"] for e in payload["by_year"])
```
(adapt to the file's existing fixtures/monkeypatching). Run the backend suite — expected PASS.
- [ ] **Step 5: Commit**
```bash
git add pipeline/plugins/extractors/tap-uk-ees pipeline/transform backend
git commit -m "fix(data): official DfE KS4 national headline averages; drop mislabelled computed means"
```
---
### Task 6: Honest 2021/22 footnote
**Files:**
- Modify: `nextjs-app/components/ComparisonChart.tsx:243-247`
- Modify: `nextjs-app/lib/compareChartData.ts` (comment lines 7, 5355 — comments only, no logic)
- Modify: `nextjs-app/__tests__/lib/compareChartData.test.ts` (test name/comment wording only)
- Modify: `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md` §8.1
**Interfaces:** none — copy and docs only. This is the one place the plan changes reviewed copy, because the reviewed copy is factually wrong (Global Constraints exception).
- [ ] **Step 1: Fix the user-facing copy**
In `ComparisonChart.tsx` replace the note:
```tsx
{built.showUnpublished202122Note && (
<p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and our dataset doesn&apos;t
yet include school-level figures for 2021/22 the England average is shown for that
year.
</p>
)}
```
- [ ] **Step 2: Fix the lying comments**
In `compareChartData.ts`, update the header comment (line 7) and the `showUnpublished202122Note` doc comment (lines 5355) to say the 2021/22 school-level figures are *absent from our dataset* (DfE published them in Dec 2022; ingesting them is a backlog pipeline task), not "unpublished". Rename nothing (the flag name stays — pure rename churn). In `compareChartData.test.ts`, adjust the test description/comment wording the same way.
- [ ] **Step 3: Correct spec §8.1**
In the spec's §8.1, replace any wording that calls 2021/22 school-level KS2 a "permanent DfE gap" with: DfE published school-level KS2 results for 2021/22 in December 2022 (with comparability caveats); they are not yet ingested — loading them remains an open pipeline task, and the chart footnote says "our dataset doesn't yet include" accordingly.
- [ ] **Step 4: Verify + commit**
Run: `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
```bash
git add nextjs-app docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md
git commit -m "fix(compare): stop attributing the missing 2021/22 school-level year to DfE"
```
---
### Task 7: Full verification, PR, and post-deploy checklist
**Files:** none new (verification + PR).
- [ ] **Step 1: Run everything**
```bash
uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q
cd nextjs-app && npx tsc --noEmit && npm test && cd ..
cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir . && cd ../..
```
Expected: all PASS.
- [ ] **Step 2: Open the PR**
Push `fix/compare-final-review-mustfix`; open a PR via the Gitea API using `git credential fill` basic auth (token-header auth 401s). PR body: summarize the five findings and fixes, link the expert review, end with the standard Claude Code attribution + session URL. Note in the body that findings 2 and 4 also need a **DAG run after the staging deploy** before the UI shows corrected data.
- [ ] **Step 3: Post-merge staging verification (after the user merges and the daily DAG runs — record results, do not promote)**
```bash
# Report card dated by its own inspection (Barclay): expect 2026-02-03
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690" | python3 -c "import json,sys; o=json.load(sys.stdin)['comparison']['138690']['ofsted']; print(o['rc_inspection_date'], o['inspection_date'])"
# Stale Watford rc grades cleared by the fresh extract: expect report_card == {}
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['comparison']['136276']['ofsted']['report_card'])"
# Official KS4 nationals: expect A8 46.0 for 202425, progress_8_score absent
curl -sk "https://stx.schoolcompare.co.uk/api/national-averages" | python3 -c "import json,sys; print(json.load(sys.stdin)['secondary'])"
# FSM benchmark real (~24-26), secondary disadvantaged_pct gone
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690,136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['benchmarks'])"
```
Then re-screenshot both phase views (desktop + mobile, "More measures" expanded, Watford Grammar in the secondary set) and hand them to the Ofsted expert agent for the sign-off pass it said it expects. Production promotion remains the human's manual call.
---
## Out of Scope (expert should-fix/minor — separate follow-ups)
- 137086-style interim state (subgrades without an overall from an RI reinspection) rendering treatment (finding 6).
- Disadvantaged cohort sizes on the attainment row (finding 7, spec §8.5).
- SEN/EAL "typical school" labelling and secondary SEN benchmark (finding 8) — note Task 4 already upgrades EAL to a pupil-weighted census figure.
- Selective-school admissions copy variant (finding 9).
- Removing/relabelling `gcse_grade_91_pct` as a compare measure (finding 10) — Task 5 already removes its false England anchor.
- Palette deviation (11), trends picker label (12), "More measures" expanded-state verification (13).
- Actually ingesting the 2021/22 school-level KS2 release (the copy in Task 6 says "doesn't *yet* include").
@@ -0,0 +1,215 @@
# Exam Results Taxonomy — Phase Grouping and Sixth-Form Separation
**Date:** 2026-07-07
**Status:** Approved design (taxonomy/analysis only — no implementation in this doc's scope)
## Purpose
Classify every exam-result metric SchoolCompare displays today into four phase
groups — **Primary**, **Secondary**, **Sixth form**, **Other** — and define an
authoritative rule for separating schools that have a sixth form from those
that don't. This document is the reference for:
1. How the UI should group results sections and rankings by phase.
2. The future KS5 (A-level) ingestion work — the Sixth form group lists the
concrete DfE metrics as placeholders with source columns.
3. Replacing the fragile `age_range contains "18"` heuristic with the GIAS
`OfficialSixthForm` flag.
## 1. Grouping principle
Metrics are grouped by **the key stage of the assessment**, not by the phase
of the school displaying them. An all-through school (418) shows metrics in
all three exam groups; a pure primary shows only the Primary group.
| Group | Assessments | Key stage | Taken at age | Data status |
|---|---|---|---|---|
| **Primary** | KS2 SATs (reading, writing TA, maths, GPS, science TA) | KS2 | 1011 (Year 6) | ✅ Live — `marts.fact_ks2_performance` |
| **Secondary** | GCSEs, Attainment 8 / Progress 8, EBacc | KS4 | 1516 (Year 11) | ✅ Live — `marts.fact_ks4_performance` |
| **Sixth form** | A levels, applied general, tech levels | KS5 (1618) | 1718 (Year 1213) | ⏳ Not ingested — placeholders in §4 |
| **Other** | Non-exam context displayed alongside results | n/a | n/a | ✅ Live — various marts |
Not covered (not displayed today, candidates for future "Other"/Primary):
EYFS Good Level of Development, Year 1 Phonics check, Year 4 Multiplication
Tables Check, KS1 assessments (no longer published at school level by DfE).
## 2. Metric-by-metric mapping (current site)
Every key in `backend/schemas.py` `METRIC_DEFINITIONS` — the single source of
truth for what the site displays — mapped to its phase group. `category` is
the existing schema category; source columns are the DfE names used at
ingestion (legacy performance-tables CSV for KS2, EES for KS4).
### Primary (KS2 SATs)
| Metric key | Category | DfE source column |
|---|---|---|
| `rwm_expected_pct` | expected | `PTRWM_EXP` |
| `reading_expected_pct` | expected | `PTREAD_EXP` |
| `writing_expected_pct` | expected | `PTWRITTA_EXP` |
| `maths_expected_pct` | expected | `PTMAT_EXP` |
| `gps_expected_pct` | expected | `PTGPS_EXP` |
| `science_expected_pct` | expected | `PTSCITA_EXP` |
| `rwm_high_pct` | higher | `PTRWM_HIGH` |
| `reading_high_pct` | higher | `PTREAD_HIGH` |
| `writing_high_pct` | higher | `PTWRITTA_HIGH` |
| `maths_high_pct` | higher | `PTMAT_HIGH` |
| `gps_high_pct` | higher | `PTGPS_HIGH` |
| `reading_progress` | progress | `READPROG` |
| `writing_progress` | progress | `WRITPROG` |
| `maths_progress` | progress | `MATPROG` |
| `reading_avg_score` | average | `READ_AVERAGE` |
| `maths_avg_score` | average | `MAT_AVERAGE` |
| `gps_avg_score` | average | `GPS_AVERAGE` |
| `rwm_expected_boys_pct` | gender | `PTRWM_EXP_B` |
| `rwm_expected_girls_pct` | gender | `PTRWM_EXP_G` |
| `rwm_high_boys_pct` | gender | `PTRWM_HIGH_B` |
| `rwm_high_girls_pct` | gender | `PTRWM_HIGH_G` |
| `rwm_expected_disadvantaged_pct` | equity | `PTRWM_EXP_FSM6CLA1A` |
| `rwm_expected_non_disadvantaged_pct` | equity | `PTRWM_EXP_NotFSM6CLA1A` |
| `disadvantaged_gap` | equity | `DIFFN_RWM_EXP` |
| `reading_absence_pct` | absence | `PTREAD_AT` |
| `gps_absence_pct` | absence | `PTGPS_AT` |
| `maths_absence_pct` | absence | `PTMAT_AT` |
| `writing_absence_pct` | absence | `PTWRITTA_AD` |
| `science_absence_pct` | absence | `PTSCITA_AD` |
| `rwm_expected_3yr_pct` | trends | `PTRWM_EXP_3YR` |
| `reading_avg_3yr` | trends | `READ_AVERAGE_3YR` |
| `maths_avg_3yr` | trends | `MAT_AVERAGE_3YR` |
The absence metrics measure absence *from KS2 tests*, so they belong to
Primary even though they are not attainment scores. National comparators for
this group come from `marts.fact_ks2_national_averages`.
### Secondary (KS4 / GCSE)
| Metric key | Category | EES source column |
|---|---|---|
| `attainment_8_score` | gcse | `attainment8_average` |
| `progress_8_score` | gcse | `progress8_average` |
| `english_maths_standard_pass_pct` | gcse | `engmath_94_percent` |
| `english_maths_strong_pass_pct` | gcse | `engmath_95_percent` |
| `ebacc_entry_pct` | gcse | `ebacc_entering_percent` |
| `ebacc_standard_pass_pct` | gcse | `ebacc_94_percent` |
| `ebacc_strong_pass_pct` | gcse | `ebacc_95_percent` |
| `ebacc_avg_score` | gcse | `ebacc_aps_average` |
| `gcse_grade_91_pct` | gcse | `gcse_91_percent` |
Also stored in `marts.fact_ks4_performance` (and `fact_performance`) but not
yet in `METRIC_DEFINITIONS` — Secondary group members when surfaced:
`progress_8_lower_ci`, `progress_8_upper_ci`, `progress_8_english`,
`progress_8_maths`, `progress_8_ebacc`, `progress_8_open`,
`prior_attainment_avg` (KS2 baseline of the GCSE cohort), `sen_pct`.
### Sixth form (KS5)
No metrics today. The secondary school detail view renders a static note
("Post-16 destination data coming soon") when the school has a sixth form.
Placeholders for ingestion are specified in §4.
### Other (non-exam context)
Displayed alongside results but not tied to any assessment:
| Metric key / surface | Category | Source |
|---|---|---|
| `disadvantaged_pct` | context | KS2 CSV `PTFSM6CLA1A` |
| `eal_pct` | context | KS2 CSV `PTEALGRP2` |
| `sen_support_pct` | context | KS2 CSV `PSENELK` (KS4 fallback `sen_no_ehcp_pupil_percent`) |
| `stability_pct` | context | KS2 CSV `PTMOBN` |
| Ofsted grades incl. `sixth_form_provision` / `rc_sixth_form` | — | `marts.fact_ofsted_inspection` |
| Admissions (offers, oversubscription) | — | `marts.fact_admissions` |
| Finance (per-pupil spend, cost shares) | — | `marts.fact_finance` |
| Deprivation (IDACI) | — | `marts.fact_deprivation` |
| Pupil characteristics (census) | — | `marts.fact_pupil_characteristics` |
Note: the context metrics are cohort characteristics of the KS2 cohort at
source, but they are presented (and should stay presented) as school-level
context, so they group as Other, not Primary.
## 3. Sixth-form separation
### Definition (authoritative)
> A school **has a sixth form** iff GIAS `OfficialSixthForm (name)` =
> `"Has a sixth form"` for its URN.
GIAS values are `Has a sixth form`, `Does not have a sixth form`, and
`Not applicable` / blank. `Not applicable` (nurseries, primaries, PRUs) maps
to **false**. This field is the DfE's registry flag, updated continuously,
and is the only source that correctly classifies:
- 1619 sixth-form colleges and UTCs (age ranges like `14-19`, `16-19` that
the current substring heuristic misclassifies as *no* sixth form);
- schools whose statutory age range extends to 18 on paper but which have no
registered post-16 provision.
### Pipeline change (implemented 2026-07-07)
1. `stg_gias_establishments.sql`: add
`"OfficialSixthForm (name)" as official_sixth_form`.
2. `dim_school.sql` (+ `models.py` `DimSchool`, `_marts_schema.yml`): add
`has_sixth_form boolean` = `official_sixth_form = 'Has a sixth form'`.
3. Expose `has_sixth_form` on the school API payloads.
Implemented in `feat/gias-sixth-form-flag` — see
`docs/superpowers/plans/2026-07-07-gias-sixth-form-flag.md`.
### Current heuristic — audit of `age_range` ~ "18" sites
All must migrate to the `has_sixth_form` flag once exposed:
| Site | Current behaviour |
|---|---|
| `backend/app.py:419-422` | `/api/schools?has_sixth_form=yes\|no` filters on `age_range.str.contains("18")` |
| `nextjs-app/components/SecondarySchoolDetailView.tsx:101` | "Sixth form" badge + coming-soon note from `age_range?.includes('18')` |
| `nextjs-app/components/FilterBar.tsx:370-372` | Filter labels hard-code "(11-18)" / "(11-16)" — labels should drop the age-range parenthetical since sixth form ≠ age range |
Fallback rule: if GIAS is blank for a URN (rare; new establishments), fall
back to the age-range heuristic and log the URN.
### UI separation rules
- **School page**: schools with `has_sixth_form = true` show a Sixth form
results section (placeholder until KS5 data lands); schools without never
show it. Badge on the header as today, but driven by the flag.
- **Search/rankings filter**: "With sixth form" / "Without sixth form" uses
the flag; applies to secondary and all-through phases.
- **Comparison**: when comparing a with-sixth-form school against one
without, the Sixth form group renders "No sixth form" for the latter
rather than blank cells, making the structural difference explicit.
## 4. Sixth form placeholders — future KS5 ingestion spec
Source: DfE "A level and other 16 to 18 results" (EES, preferred — matches
the KS4 EES tap) or legacy performance-tables `england_ks5final.csv`.
Column names below are from the legacy KS5 CSV; verify against the EES
release chosen at ingestion time.
| Proposed metric key | Name | Legacy source column | Type |
|---|---|---|---|
| `alevel_aps_per_entry` | A level average points per entry | `TALLPPE_ALEV_1618` | score |
| `alevel_avg_grade` | A level average grade (e.g. B-) | `TALLPPEGRD_ALEV_1618` | grade |
| `academic_aps_per_entry` | Academic qualifications APS per entry | `TALLPPE_ACAD_1618` | score |
| `applied_general_aps_per_entry` | Applied general APS per entry | `TALLPPE_AGEN_1618` | score |
| `tech_level_aps_per_entry` | Tech level APS per entry | `TALLPPE_TLEV_1618` | score |
| `english_progress_1618` | English progress (1618, unfinished GCSE 4+) | `PROGENG_1618` | score |
| `maths_progress_1618` | Maths progress (1618) | `PROGMAT_1618` | score |
| `ks5_cohort_size` | Students at end of 1618 study | `TALLPUP_1618` | count |
| `alevel_3plus_aab_pct` | % achieving AAB+ in ≥2 facilitating subjects | `TAAB2FAC_1618` | percentage |
| `ks5_retention_pct` | Retention (completed main programme) | study-programme retention measure | percentage |
| `ks5_destinations_pct` | Sustained education/employment destination | 1618 destination measures dataset | percentage |
Proposed landing shape mirrors KS4: `stg_ees_ks5.sql`
`int_ks5_with_lineage.sql``marts.fact_ks5_performance` (one row per URN
per year), joined into `fact_performance`, with a `category: "sixth_form"`
(or `"alevel"`) block added to `METRIC_DEFINITIONS`.
## 5. Out of scope
- Any implementation (pipeline, API, or UI changes) — this is the taxonomy
reference; implementation work items are §3 "Pipeline change", the
heuristic migration audit, and §4 ingestion, each to be planned separately.
- Middle schools (deemed secondary/primary): they follow the assessment-based
grouping automatically — no special casing.
- Independent schools: no DfE performance data published; unaffected.
@@ -0,0 +1,182 @@
# GIAS Code Dictionaries — Codes in Marts, Names in Code
**Date:** 2026-07-09
**Status:** Implemented 2026-07-09 — see docs/superpowers/plans/2026-07-09-gias-code-dictionaries.md
## Goal
Six GIAS classification fields are stored in the marts as repeated name
strings. Replace them with the official DfE integer codes and translate
code → name in application code. After this change the marts carry only
codes for:
| GIAS field | Today (marts, string) | After (marts, int) |
|---|---|---|
| `TypeOfEstablishment (name)` | `dim_school.school_type` | `school_type_code` |
| `EstablishmentStatus (name)` | `dim_school.status` | `status_code` |
| `PhaseOfEducation (name)` | `dim_school.phase` | `phase_code` |
| `OfficialSixthForm (name)` | (already reduced to `has_sixth_form` bool) | `official_sixth_form_code` in staging only; mart keeps the bool |
| `ReligiousCharacter (name)` | `dim_school.religious_character` | `religious_character_code` |
| `AdmissionsPolicy (name)` | `dim_school.admissions_policy` | `admissions_policy_code` |
Motivation: smaller marts and stable enum values for filtering. (Honest
sizing note: at ~25k open schools the raw performance win is modest; the
durable benefits are storage, DfE-governed vocabulary, and filter values
that can't drift with GIAS renames.)
## Decisions (made during brainstorming)
1. **GIAS native codes**, not custom enums. The GIAS bulk CSV publishes an
official `X (code)` column beside every `X (name)` column. We ingest the
DfE's own codes; no invented mapping to maintain.
2. **Translation lives in the backend at the API boundary.** The API keeps
serving today's name strings; the frontend, e2e journeys, and API
consumers are untouched.
## Design
### 1. Tap (Singer schema)
Add the six `(code)` columns to `GIASEstablishmentsStream.schema` in
`pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py`:
```
"TypeOfEstablishment (code)", "EstablishmentStatus (code)",
"PhaseOfEducation (code)", "OfficialSixthForm (code)",
"ReligiousCharacter (code)", "AdmissionsPolicy (code)"
```
The `(name)` columns **stay declared** — raw keeps both so we can detect
dictionary drift (§4) and regenerate dictionaries from live data.
### 2. Staging (`stg_gias_establishments.sql`)
- Add int casts: `school_type_code`, `status_code`, `phase_code`,
`official_sixth_form_code`, `religious_character_code`,
`admissions_policy_code` (all `cast(nullif(trim(...), '') as integer)`).
- Remove the corresponding name columns from the staging select
(`school_type`, `status`, `phase`, `official_sixth_form`,
`religious_character`, `admissions_policy`). Names live only in raw.
### 3. Marts
**`dim_school`** stores codes only:
- `school_type_code`, `status_code`, `phase_code`,
`religious_character_code`, `admissions_policy_code` replace their
string columns.
- Status filter becomes `where status_code in (<open>, <proposed-to-close>)`.
The numeric values are read from live raw data at implementation time
(`select distinct "EstablishmentStatus (code)", "EstablishmentStatus (name)"`),
never assumed from memory. Same filter in `dim_location`.
- `has_sixth_form` derives from `official_sixth_form_code`
(`<has-code>` → true, `<does-not>/<not-applicable>` → false, null →
`statutory_high_age >= 18` fallback). The `lower(trim(...))` string guard
becomes obsolete and is removed.
- `phase_code` derivation keeps today's cascade but emits codes:
1. GIAS `phase_code` when it is a real value (not the not-applicable code);
2. statutory-age inference emits the matching GIAS code
(Primary / Secondary / All-through — numeric values confirmed from
live data at implementation);
3. school-name heuristics (unchanged — they match `school_name`, which is
not one of the six fields) emit the same codes;
4. else null.
- dbt schema tests: `accepted_values` (severity **warn**) on every code
column, values taken from the dictionary; `not_null` warn on `phase_code`
(mirrors today's phase test); `has_sixth_form` tests unchanged.
**`dim_location`**: only the status filter changes (must stay byte-identical
to `dim_school`'s — the API inner-joins the two).
### 4. Dictionaries
**Canonical module: `backend/gias_codes.py`**
```python
ESTABLISHMENT_STATUS: dict[int, str]
SCHOOL_TYPE: dict[int, str]
PHASE_OF_EDUCATION: dict[int, str]
OFFICIAL_SIXTH_FORM: dict[int, str]
RELIGIOUS_CHARACTER: dict[int, str]
ADMISSIONS_POLICY: dict[int, str]
def translate(code: int | None, mapping: dict[int, str]) -> str | None:
"""None -> None; unknown code -> 'Unknown (<code>)' + warning log."""
```
- Contents are generated from live raw data
(`SELECT DISTINCT code, name FROM raw.gias_establishments ...` per field)
and sanity-checked against the DfE GIAS registers. Names must be
byte-identical to what the API serves today.
- Unknown codes never blank the UI: `translate` returns `"Unknown (<code>)"`
and logs, so a new DfE value degrades gracefully.
**Pipeline copy: `pipeline/scripts/gias_codes.py`**
The app and pipeline Docker images have disjoint build contexts
(`Dockerfile` copies `backend/`; `pipeline/Dockerfile` copies `pipeline/`),
so the Typesense sync cannot import the backend module. It gets a
byte-identical copy, and a backend unit test asserts
`backend/gias_codes.py` and `pipeline/scripts/gias_codes.py` have identical
content — drift fails CI. (Deliberately chosen over codegen: six dicts do
not justify build machinery.)
**Seed for drift detection: `pipeline/transform/seeds/gias_code_names.csv`**
Columns `field,code,name` mirroring the dictionary. A dbt test (severity
warn) compares live raw `(code, name)` pairs against the seed; when DfE adds
or renames a value the nightly run warns, prompting a dictionary + seed
update in one PR.
### 5. Backend translation (API contract unchanged)
- `_MAIN_QUERY` selects the code columns instead of the name columns.
- `load_school_data_as_dataframe()` translates immediately after
`pd.read_sql`, writing today's column names:
```python
df["phase"] = df["phase_code"].map(...)
df["school_type"] = df["school_type_code"].map(...) # then normalize_school_type as today
df["status"] = df["status_code"].map(...)
df["religious_denomination"] = df["religious_character_code"].map(...)
df["admissions_policy"] = df["admissions_policy_code"].map(...)
```
Everything downstream — `PHASE_GROUPS`, filters, payload builders,
`/api/filters`, frontend, e2e — sees exactly today's strings. No frontend
changes.
- `backend/models.py` `DimSchool`: string columns replaced by
`*_code = Column(Integer)`.
### 6. Typesense sync
`pipeline/scripts/sync_typesense.py` selects `phase`, `school_type`,
`religious_character` today. It switches to the code columns and translates
via `pipeline/scripts/gias_codes.py` before indexing, so facet values in
search are unchanged.
### 7. Rollout
- No DB migration: marts are full-rebuild tables.
- Deploy window: until the first post-merge pipeline run, the old marts
still carry string columns while the new backend queries code columns, so
the backend's query fails and it serves empty data (the one-column retry
built for `has_sixth_form` doesn't generalise to six columns, and a full
old-schema fallback query isn't worth it). **Decision: accept the window
and close it operationally — the runbook is merge → deploy → trigger
`school_data_daily` immediately.** The DAG's final step already calls
`/api/admin/reload`, so the backend recovers without a restart.
- Tests: backend unit tests for `translate()` (known / unknown / None),
payload tests asserting names still served, the file-parity test, dbt
schema/seed tests. Frontend: no changes; existing Jest suite is the
regression net.
## Out of scope
- Recoding other string columns (`gender`, `urban_rural`,
`nursery_provision`, `local_authority_name` …) — same pattern can follow
later if this proves out.
- Collapsing academy subtypes (today's `normalize_school_type`) — kept
as-is, applied after translation.
- Serving codes through the API — the contract deliberately keeps names.
@@ -0,0 +1,177 @@
# Compare Screen Redesign — Expert Data Review
**Date:** 2026-07-11
**Reviewer:** subagent briefed as an English education-standards / DfE-Ofsted data expert
**Subject:** desktop + mobile compare mockups and the redesign spec
(`2026-07-11-compare-screen-redesign-design.md`)
**Status:** first-pass must-fixes applied 2026-07-12; second-pass
findings (below) applied 2026-07-12 — mockups + spec §4/§8 updated
## Must-fix
1. **COVID gap is wrong and drops a real results year.** KS2 tests were
cancelled 2019/20 and 2020/21 only; they resumed in 2021/22 with
published school-level results (England RWM ≈ 59%). The mockup charts
omit 2021/22 entirely and the tooltip claims no tests were held
2019/202021/22. Fix: add 2021/22 to axis and all series; shrink the
gap band; optionally annotate 2021/22 with DfE's post-pandemic
comparability caution.
2. **Report-card at-a-glance summary miscounts areas.** Detail list has
4 Strong / 2 Expected / 1 Attention needed + Safeguarding met, but
the summary says "3 areas Expected standard" — it counts safeguarding
as a graded area. Safeguarding is a separate binary judgement and
must be excluded from rating counts.
3. **"Where the offers went" derivation is unsound.** Places 1st-pref
offers ≠ "second or third choices": the residual can include 4th6th
preference offers (pan-London scheme) and LA-allocated children who
didn't choose the school; and offers don't necessarily equal PAN.
Use the real 2nd/3rd-preference fields being promoted from
`raw.ees_admissions`; until then drop the row.
4. **Ofsted timeline in the copy is wrong.** Overall grades were
abolished September 2024, not November 2025; Sept 2024Nov 2025
inspections kept the four key judgements without an overall grade
(ungraded inspections carried grades forward). Neither mockup shows
the interim regime, which will dominate real comparisons. Fix copy
and add an interim example.
5. **Barclay's "published an overall grade only — no area-by-area
detail" misdescribes inspections.** No inspection type does that; a
2021 graded inspection necessarily had subgrades — the gap is in our
dataset. If it was an ungraded (s8) inspection, "Outstanding" is a
carried-forward grade and should say so. Fix: "We don't hold
area-by-area detail for this inspection", and distinguish graded vs
ungraded in the data model.
## Should-fix
6. Writing is teacher assessment, not a test — "national tests and
teacher assessments"; note TA caveat on the Writing strip.
7. Verify renewed-framework wording against Ofsted's final toolkit:
likely "Needs attention" (not "Attention needed") and "Personal
development and well-being" (which otherwise collides with the
identically-named legacy judgement). Pin every label to the
published toolkit.
8. "Expected standard" now means two things on one page (Ofsted area
rating vs KS2 measure) — disambiguate in tooltips.
9. Disadvantaged row: DfE definition includes looked-after / previously
looked-after children, not just FSM6; benchmark labels inconsistent
across desktop/mobile; subgroup percentages need cohort sizes or a
volatility threshold before chips are attached.
10. "Trend, last 7 years" spans ten years; sparklines render the COVID
gap as equal spacing (the exact defect the audit criticises) and
"Improved: 52% → 87%" endpoint-cherry-picks a volatile series.
11. At-a-glance "Getting a place" uses different metrics per school
(Barclay is also oversubscribed on total preferences but shows a
green chip). Standardise on first-preference success %. Explain the
equal-preference rule; condition "living close by matters" on the
school's actual oversubscription criteria.
12. "457 applications for 180 places" = total preferences at any rank,
not head-to-head applicants; lead with first preferences vs places.
Add offers-vs-final-intake (waiting lists/appeals) caveat.
13. Elmhurst's subgrade list is likely missing Early years provision
(school has a nursery) — possible pipeline gap.
14. "Ofsted rating" label is obsolete post-Sept-2024 — use "Latest
Ofsted inspection"; check whether Oct 2021 is the latest inspection
or merely the latest graded one.
15. SEN: "EHCP plans" is redundant; 28% SEN support often indicates
resourced provision — add a note; England SEN-support ≈ 14%, not 13%.
## Nice-to-have
16. Consistent labelling of official DfE vs dataset-computed benchmarks
(and medians shouldn't be called averages inconsistently).
17. England 2015/16 RWM (53%) exists in DfE publications — the null is
a dataset gap; source it or the England line looks broken.
18. "1 in 4 first choices missed out" — actually more than 1 in 4.
19. "1,273 of 1,260 places (full)" is over capacity; capacity figures
are often stale — say "at or above capacity".
20. State the actual suppression rule (DfE: ≤5 pupils suppressed,
small numbers rounded) instead of "a handful".
21. Spec §4.3 progress chips can't exist for displayed years: KS2
progress ended with 2022/23 (no KS1 baseline) and returns
~2027/28 with the reception baseline. Make explicit in the spec.
IDACI (spec §4.5) is absent from mockups; if shipped, caveat it
describes pupils' neighbourhoods, not the school.
22. Tooltips should give the official term "first preference" alongside
the plain-English "first choice".
## Overall assessment (verbatim gist)
The bones are genuinely good by education-data standards —
England-average anchoring, explicit non-comparability messaging across
Ofsted regimes, refusal to synthesise an overall grade, time-true
x-axis, neutral FSM/EAL framing — better than most commercial
school-comparison sites. But items 15 are outright factual errors or
misdescriptions that a well-informed parent or Ofsted would catch;
the admissions section needs the most conceptual work (equal
preference, preferences-vs-applicants, offers-vs-intake). Fix 15
before user testing; the rest fold into the planned PRs.
---
# Second-pass review (2026-07-12)
Same reviewer, after the must-fixes and the new three-tier metric
exposure model were applied.
## Verification of first-pass must-fixes
- **1 (COVID/2021/22): resolved.** Time-true axis, band covers only the
cancelled years, England 58.7% consistent with official figures,
dataset gaps break lines honestly; reading/maths England series all
match published figures; RWM ≤ min(subject) checks pass.
- **2 (report-card count): resolved** — safeguarding excluded, spec §8.2.
- **3 (offers derivation): resolved** — row removed, spec §8.3 bans it.
- **4 (Ofsted timeline): resolved on desktop; mobile omits the interim
regime clause** (see finding 6).
- **5 (Barclay explanation): resolved.**
## New findings
1. **Should-fix — scaled-score strip domain contradicts caption.**
Caption says "scaled scores run 80120", strips render 100120;
truncated domain exaggerates small gaps and below-100 averages
would fall off the edge. Render 80120, or caption the 100120
window honestly and define below-100 behaviour.
2. **Should-fix — scaled-score England ticks (106/105/105) unsourced.**
Plausible but hand-entered; verify against DfE 2024/25 tables and
add loading official England scaled scores to the pipeline list
(absent from §8.1/§8.6).
3. **Should-fix — "Writing" listed under "Higher standard" in the
picker.** Writing TA outcome is "greater depth" (GDS), never
"higher standard". Label "Writing — greater depth (teacher
assessment)"; tooltip the combined higher-standard composition.
4. Nice — "grammar & punctuation" summary line drops "spelling" (GPS).
5. Nice — science is teacher-assessed (no KS2 test since 2009) and
coarse; tooltip it like writing; reconsider its tier-2 slot.
6. **Should-fix — mobile Ofsted copy skips the interim regime**
(Sept 2024Nov 2025) that desktop explains. One clause fixes it.
7. **Should-fix — benchmark provenance still inconsistent** (EAL
tooltip unsourced; FSM/disadvantaged chips vs tooltips use three
vocabularies; header note says all England averages are official).
Adopt one house style: official = "England average", computed =
"benchmark / typical state school (our dataset)". Also tighten EAL
definition to census wording ("first language known or believed to
be other than English").
8. Nice — "community primaries" distance note attached to an academy
(Elmhurst); say "non-faith primaries" or condition on policy field.
9. Nice — "Improving since 2022" → "since 2022/23".
10. Nice — England chart tooltips show decimals; §7 mandates whole
percents.
## Residual gaps not covered by spec §8
11. Spec promises IDACI-in-words, Attendance section, and tier-2
gender/absence that the mockups never show — mark post-v1 or
demonstrate, so implementation scope is unambiguous.
12. Add official England scaled-score averages to the pipeline task
list.
13. Add the writing/greater-depth terminology rule to §8.7.
## Verdict
All must-fixes genuinely resolved; the tier model is conceptually
sound ("no measure is lost", honest dataset-gap breaks, grouped
picker). Remaining issues are contained: one internal contradiction
(80120 vs 100120), one provenance inconsistency, one terminology
error (writing/GDS). With findings 13 and 67 addressed, the data
framing is fit to put in front of parents.
@@ -0,0 +1,329 @@
# Compare Screen Redesign — Audit & Design
**Date:** 2026-07-11
**Status:** Draft — awaiting review
**Scope:** `/compare` page (nextjs-app), `/api/compare` endpoint (backend)
## 1. Audit of the current screen
The current compare page (`nextjs-app/components/ComparisonView.tsx`) is a
single-metric analyst tool: a `<select>` with ~40 KS2/GCSE metrics, one
line chart over time, and a year-by-year table — all for the one selected
metric. Observed on production with 3 primary schools:
**What works**
- URL-shareable state (`?urns=…&metric=…`), native share sheet.
- Phase tabs (primary/secondary) with sensible auto-detection.
- Colour-coded school cards tied to chart series.
- Metric descriptions from `/api/metrics` (single source of truth).
**What doesn't**
1. **Performance-only.** The database already holds Ofsted inspections,
admissions/oversubscription history, pupil characteristics (FSM/EAL),
SEN, deprivation (IDACI), finance, capacity, faith, gender, trust —
none of it reaches the compare screen. `/api/compare` returns only
`yearly_data` + minimal `school_info`, while `/api/schools/{urn}`
already returns all supplementary blocks.
2. **One metric at a time.** A parent must know which of ~40 metrics
matters, select each in turn, and hold results in their head. There is
no side-by-side overview and no way to see two dimensions at once.
3. **No benchmarks.** Numbers float without anchors: is 79% RWM good?
The DB has official national averages (`fact_ks2_national_averages`)
but the page never shows them.
4. **Domain jargon untranslated.** "GPS Expected %", "Progress scores",
"RWM Combined" assume DfE literacy. The only plain-English help is one
note for progress scores.
5. **Raw numbers, no judgement support.** 87.0% vs 92.0% vs 79.0% — the
page never says "all three are well above the England average of 62%",
which is the fact a parent actually needs.
6. **Bugs/paper cuts observed:** the third school's series did not render
on the production chart despite table data (worth a separate fix);
the COVID gap (2018/19 → 2022/23) renders as equal spacing with no
annotation; table shows "87.0%" precision that implies false accuracy.
## 2. Data inventory (available vs shown)
| Domain | Source table | On detail page | On compare |
|---|---|---|---|
| KS2 attainment/progress | fact_ks2_performance | yes | **yes** (only thing shown) |
| National averages | fact_ks2_national_averages | partial | no |
| Ofsted (latest + subgrades + report-card fields) | fact_ofsted_inspection, dim_school | yes | no |
| Admissions & oversubscription (multi-year) | fact_admissions | yes | no |
| Pupil characteristics (FSM, EAL, gender split) | fact_pupil_characteristics | yes | no |
| Context (SEN, disadvantaged, stability, absence) | fact_ks2_performance | via metric picker | buried in picker |
| Deprivation (IDACI) | fact_deprivation | yes | no |
| Finance (per-pupil spend) | fact_finance | yes | no |
| School facts (capacity, faith, ages, trust, nursery, gender) | dim_school | yes | no |
| Location/distance | dim_location | map | no |
## 3. Design goals
1. **Answer parent questions, in order:** Is it a good school (Ofsted)?
Do children do well there (academics vs England)? Will my child get a
place (admissions)? What is the school like (size, community, faith)?
2. **Every number gets an anchor** — the England average, rendered as a
consistent visual tick, plus a plain-English chip
(Above / Close to / Below England average).
3. **Plain English first, jargon on demand.** Labels are questions or
sentences ("Children reaching the expected standard in reading,
writing and maths"), codes/acronyms live in tooltips.
4. **Scan whole-picture first, drill down second.** The single-metric
trend explorer survives, demoted to an "Explore trends" section at the
bottom rather than being the entire page.
## 4. Proposed structure
Columns = schools (max 4 visible on desktop, horizontal scroll beyond),
rows = dimensions. Sticky compact school header keeps column identity
while scrolling. Sections, in order:
1. **At a glance** — verdict row per school: Ofsted badge, headline
attainment vs England (dot strip + chip), oversubscription chip,
size, distance (when a location is set).
2. **Ofsted inspection** — must handle all three inspection regimes,
which will coexist in comparisons for years:
- **Legacy graded (pre-Sept 2024):** overall grade badge
(Outstanding/Good/Requires improvement/Inadequate). Subgrades,
where published, are rendered in the **same area-by-rating chip
list UX as report cards** (one row per judgement area, rating as
a chip) — one visual grammar for inspection detail across both
regimes. Where our dataset has no subgrades for an inspection,
say so honestly ("We don't hold area-by-area detail for this
inspection") and point to the school's Ofsted page — never claim
the inspection itself published no detail (graded inspections
always have subgrades; if it was ungraded, the grade is
carried forward and must be labelled as such).
- **Interim ungraded (Sept 2024 Nov 2025):** parsed outcome
("remains Good") shown as the effective grade, marked as such.
- **Renewed framework report card (from Nov 2025):** no overall
grade exists. Render the report card as an area-by-rating list
using Ofsted's 5-point scale (Exceptional / Strong standard /
Expected standard / Attention needed / Urgent improvement) across
the evaluation areas we model (`rc_inclusion`,
`rc_curriculum_teaching`, `rc_achievement`,
`rc_attendance_behaviour`, `rc_personal_development`,
`rc_leadership_governance`, `rc_early_years`, `rc_sixth_form`)
plus the separate safeguarding met/not-met flag. **At-a-glance
summary rule:** never an unlabelled colour strip — summarise by
counting areas per rating, best first ("5 areas Strong standard ·
3 areas Expected standard"), and always name any area rated
Attention needed or Urgent improvement explicitly (never fold
problems into a count), plus "Safeguarding not met" whenever that
flag is false. When everything is Expected standard or better,
add the reassurance line "No areas need attention".
When a comparison mixes regimes, show a one-line comparability note
("Ofsted changed how it reports in Nov 2025 — a report card and an
older overall grade aren't directly comparable"). Never derive a
fake overall grade from report-card areas.
3. **Academics (KS2)** — one dot-strip row per headline measure (RWM
expected, RWM higher, reading/writing/maths expected), each with the
England-average tick and per-school dots; copy must say "tests and
teacher assessments" (writing is TA, not a test). Progress scores
translated to Above/Average/Below chips (CI-based) — **but note KS2
progress measures ended with 2022/23** (no KS1 baseline afterwards)
and return only when the reception-baseline cohort reaches Y6
(~2027/28), so progress chips apply to historical years in the
trends explorer, not the headline view. Sparkline per school over
the full published period, with an honest gap for the cancelled
test years (2019/202020/21). Disadvantaged-pupils row under an
"Equity" subheading, always with cohort size shown and DfE's full
definition (FSM6 **or** looked-after/previously looked-after).
4. **Getting a place** — oversubscription ratio as plain sentence
("184 applications for 80 places"), first-preference success %, trend
vs last year, admissions policy.
5. **Who goes there** — pupils on roll (vs capacity), boys/girls, FSM %,
EAL %, SEN support %, faith, ages, nursery, trust. *Post-v1:* IDACI
decile in words (needs a coverage check of `fact_deprivation` and
the neighbourhood-not-school caveat, §8.7).
6. **Attendance***post-v1.* The KS2 test-day absence fields are the
only per-school absence data we hold; they're near-zero for most
schools and easy to misread as general attendance. Ship only if a
general-absence source lands.
7. **Explore trends** (existing feature, collapsed) — metric picker +
multi-year line chart + table, with an added England-average
reference line and a COVID-gap annotation.
**Metric exposure model (three tiers).** No measure from the current
page is lost; they surface at three levels of prominence:
- **Tier 1 — headline strips (always visible):** RWM expected,
reading/writing/maths expected, RWM higher standard.
- **Tier 2 — "More measures" expansion inside Academics:** GPS and
science expected % (science labelled teacher-assessed), average
scaled scores (reading/maths/GPS, same dot-strip grammar showing
the 100120 window of the 80120 scale, widening below 100, with
the England tick) — one tap/click away, same visual language.
*Post-v1:* gender split and absence (see §4.6).
- **Tier 3 — Explore trends:** the full grouped catalogue (the
current page's ~40 metrics, including equity and school-context
measures, and the GCSE set for secondary phase) drives the
year-by-year chart and table via the grouped metric picker.
The tier assignment is a content decision per phase (secondary:
Attainment 8, Progress 8 banding, grade 5+ English & maths as tier 1;
EBacc and subject entries as tier 2).
Finance (per-pupil spend) is deliberately deferred: low parent value,
risk of misreading. Revisit later.
**Mobile (design target — mobile first):** the desktop grid is the
adaptation, not the other way round. On mobile the layout goes
*measure-first*: each row is one measure with all schools listed under
it (colour dot + short name + value + chip), so comparison never
requires horizontal swiping between school cards. A sticky horizontal
school-chip bar keeps identity and add/remove available while
scrolling. Dot strips already read measure-first and carry over
unchanged. The trend chart scrolls horizontally inside its container.
## 5. Data strategy — existing dataset only
Constraint (agreed 2026-07-11): use only data already in marts plus
fields already present in the `raw` schema extracts we pull today.
No new external sources.
**Gaps in the mockup, resolved within this constraint:**
| Mockup element | Resolution |
|---|---|
| England average for disadvantaged pupils | Compute from our own data: `stg_ees_ks2` already pivots the Disadvantaged breakdown per school; aggregate it (weighted by eligible pupils) into `fact_ks2_national_averages` or compute in the API. Label it "England average (state schools)". |
| England context for FSM / EAL / SEN chips | Compute dataset-wide medians per phase, same pattern as `/api/national-averages` does for KS4. |
| "Much larger than average" size label | Dataset median pupils-on-roll per phase. |
| Ofsted link | We don't have deep links to the latest report, so always link to the school's Ofsted provider page, `https://reports.ofsted.gov.uk/provider/21/{urn}`, derived from URN (label it "the school's Ofsted page", not "the report"). |
**Raw fields we already pull but don't store — promote to marts (one
dbt/pipeline PR, no tap changes):**
- `raw.ees_admissions`: 2nd/3rd preference applications and offers,
total-preference counts, cross-LA applications and offers → richer
"Getting a place" (e.g. "offers reached 2nd-choice families",
competition from outside the borough).
- `raw.ees_ks2_attainment`: progress-measure confidence intervals and
"working towards" % → lets the Above/Average/Below progress chips be
statistically honest (band by CI overlap with 0, mirroring DfE
methodology) instead of thresholding the point estimate.
- `raw.ees_ks4_performance` / `ees_ks4_info`: `progress8_banding`
(DfE's own plain-English "well above average … well below average"
label — exactly the chip we want for secondary), EBacc entry/APS,
grade-5+ English & maths, `attainment8_diffn`/`progress8_diffn`
(disadvantage gaps) → the secondary-phase version of the Academics
section.
- `raw.ees_census`: young-carer % and the ethnicity breakdown →
optional "Who goes there" enrichment; hold for a later iteration
(presentation needs care), but the data requires no new extract.
- `raw.ofsted_inspections` / tap-uk-ofsted: the `rc_*` report-card
columns exist in staging/marts but are stubbed `null` — the tap has a
TODO to map the report-card column names from the Ofsted MI file
(same monthly extract we already download; inspections from Nov 2025
onward carry them). This is the one promotion that needs a small tap
schema addition, and it's a prerequisite for the new-framework Ofsted
display above.
Explicitly out (not in any current extract): school-level phonics,
workforce/teacher data, per-school attendance beyond the KS2 test-day
absence fields, Ofsted report-card documents themselves.
## 6. API changes
Extend `GET /api/compare` response per URN with the same supplementary
blocks the detail endpoint already builds (`get_supplementary_data`):
`ofsted`, `census`, `admissions` (+ `admissions_history`), `deprivation`,
plus a top-level `national_averages` block for the latest year. Reuse the
existing function; no new tables. Response stays backward-compatible
(additive fields only). Add derived helper fields server-side or compute
chips client-side from `national_averages` (client-side preferred — no
schema churn).
## 7. Accessibility & comprehension devices
- Verdict chips are text + colour + position (never colour alone).
- Every acronym has a tooltip using existing `MetricTooltip`.
- "How to read this" one-liner at the top of each section.
- Chart palette: coral `#e07256`, teal `#00949b`, purple `#8664c9`
(validated: lightness band, chroma, CVD separation, contrast — the
current `--chart-2/-4` tokens fail chroma/contrast checks and should
be nudged to these).
- Numbers rounded to whole percents; England tick labelled on first use.
## 8. Expert-review requirements
An adversarial review by an education-data expert (full findings in
`2026-07-11-compare-screen-expert-review.md`) was applied to the
mockups on 2026-07-12. The following are binding requirements for
implementation, beyond what the mockups can show:
1. **Chart truthfulness:** KS2 tests were cancelled 2019/202020/21
only. **2021/22 school-level figures are a permanent source gap**
DfE stated it would not publish KS2 2021/22 in performance tables
(verified 2026-07-12 against EES, the CSP download service, and
DfE release notes; see `# TASK 6 VERIFICATION` in
`pipeline/scripts/diagnose_compare_gaps.py`; re-verified 2026-07-16
after an expert-review challenge — the GOV.UK statistics announcement
"Primary school performance tables: 2022" is marked CANCELLED with
"will not be published in key stage 2 performance tables in academic
year 2021/22", so the footnote's "DfE didn't publish" claim stands
and must not be softened to "not in our dataset"). The chart's England-
only 2021/22 point with broken school lines is therefore the
correct permanent rendering; copy should say "DfE didn't publish
school-level figures for 2021/22", not "not in our dataset yet".
The 2015/16 national figure and the GPS/science/scaled-score
England averages ARE loadable (mapping already correct; refreshed
raw extract backfills them). Never render missing years as if time
were continuous.
2. **Report-card summaries** count graded areas only — safeguarding is
a separate binary flag, never included in rating counts.
3. **Admissions:** use the real preference-breakdown fields from
`raw.ees_admissions`; never derive "lower-preference offers" as
places first-preference offers. Frame total applications as
"named on N forms" (any rank), lead with first-preference success,
and standardise at-a-glance chips on that one metric. Explain the
equal-preference rule; caveat offers vs final intake (waiting
lists/appeals); condition "distance decides" on the school's actual
oversubscription criteria where we have the admissions-policy field.
4. **Ofsted:** overall grades ended September 2024 (report cards from
November 2025); the interim regime must be renderable. Distinguish
graded (s5) vs ungraded (s8) inspections and surface carried-forward
grades as such; "we don't hold the detail" is a statement about our
dataset, never about the inspection. Verify every scale/area label
against Ofsted's final published toolkit before launch (e.g. "Needs
attention" vs "Attention needed"; "Personal development and
well-being" vs the identically-named legacy judgement). Check
whether a school's latest inspection is merely its latest *graded*
one. Confirm Early years provision subgrades flow through the
pipeline for schools with nurseries.
5. **Subgroup honesty:** disadvantaged-pupil percentages carry cohort
sizes and follow the DfE suppression rule (≤5 pupils suppressed);
state the rule verbatim in the footer.
6. **Benchmark provenance:** official DfE figures and
dataset-computed benchmarks must be labelled distinctly and
consistently everywhere (a computed median is a "benchmark",
not an "England average").
7. **Copy details:** "Latest Ofsted inspection" (not "Ofsted rating");
"EHC plans"; SEN-support benchmark ≈14%; high SEN share may
indicate resourced provision (say so neutrally); "at or above
capacity" rather than "full" (capacity data is often stale);
disambiguate Ofsted's "Expected standard" from the KS2 measure;
give official terms ("first preference") alongside plain English.
Writing has no "higher standard" — its TA outcome is "greater
depth (GDS)"; never list writing under a higher-standard group.
Science and writing are teacher-assessed and must be labelled as
such (no KS2 science test since 2009). House style for benchmark
provenance: official DfE figures say "England average"; computed
figures say "state-school average (computed from our dataset)" —
applied to every chip, tooltip, header note and section intro.
EAL uses the census wording: first language known or believed to
be other than English. If IDACI ships, caveat that it describes
pupils' home neighbourhoods, not the school.
## 9. Rollout
1. **PR 1 (backend):** extend `/api/compare` + tests.
2. **PR 2 (frontend):** new compare layout behind the existing route;
e2e journey updated in the same PR (promotion gate).
3. **Fix separately:** missing third series on the current chart.
## 10. Open questions for review
- Max schools: keep 10 in API but cap visible columns at 4 with scroll?
- Should distance-from-home appear when the user searched by postcode
(data exists via `dim_location`)?
- Keep finance out of v1? (Recommended: yes, out.)
@@ -0,0 +1,642 @@
<title>Compare screen — proposed redesign</title>
<style>
:root {
--bg: #faf7f2;
--bg-2: #f3ede4;
--card: #ffffff;
--ink: #1a1612;
--ink-2: #5c564d;
--ink-3: #6d685f;
--border: #e5dfd5;
--accent: #b04a2e;
--accent-bg: rgba(224, 114, 86, 0.12);
/* Validated series palette (passes lightness / chroma / CVD / contrast) */
--s1: #e07256; --s1-text: #b04a2e;
--s2: #00949b; --s2-text: #006a70;
--s3: #8664c9; --s3-text: #6a4bab;
--eng: #6d685f;
--good-bg: #e3efe6; --good-text: #1a6b34;
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
--bad-bg: #f7e3de; --bad-text: #a03a20;
--neutral-bg: #efeae1; --neutral-text: #5c564d;
--shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
--display: 'Playfair Display', Georgia, 'Times New Roman', serif;
--body: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
--s1: #f08b6e; --s1-text: #f4a58e;
--s2: #2fb8ae; --s2-text: #5fd0c8;
--s3: #a98fe0; --s3-text: #c0abec;
--eng: #9a927f;
--good-bg: #1e3325; --good-text: #7fd39a;
--warn-bg: #38301a; --warn-text: #e4c268;
--bad-bg: #3d221b; --bad-text: #f0977f;
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
--shadow: 0 2px 8px rgba(0,0,0,0.35);
}
}
:root[data-theme="dark"] {
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
--s1: #f08b6e; --s1-text: #f4a58e;
--s2: #2fb8ae; --s2-text: #5fd0c8;
--s3: #a98fe0; --s3-text: #c0abec;
--eng: #9a927f;
--good-bg: #1e3325; --good-text: #7fd39a;
--warn-bg: #38301a; --warn-text: #e4c268;
--bad-bg: #3d221b; --bad-text: #f0977f;
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
--shadow: 0 2px 8px rgba(0,0,0,0.35);
}
:root[data-theme="light"] {
--bg: #faf7f2; --bg-2: #f3ede4; --card: #ffffff;
--ink: #1a1612; --ink-2: #5c564d; --ink-3: #6d685f;
--border: #e5dfd5; --accent: #b04a2e; --accent-bg: rgba(224,114,86,0.12);
--s1: #e07256; --s1-text: #b04a2e;
--s2: #00949b; --s2-text: #006a70;
--s3: #8664c9; --s3-text: #6a4bab;
--eng: #6d685f;
--good-bg: #e3efe6; --good-text: #1a6b34;
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
--bad-bg: #f7e3de; --bad-text: #a03a20;
--neutral-bg: #efeae1; --neutral-text: #5c564d;
--shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
}
body { background: var(--bg); color: var(--ink); font-family: var(--body); line-height: 1.5; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1.25rem 5rem; }
.mock-note {
background: var(--accent-bg); border: 1px solid var(--border); border-radius: 8px;
padding: 0.6rem 1rem; font-size: 0.85rem; color: var(--ink-2); margin-bottom: 2rem;
}
.mock-note strong { color: var(--accent); }
h1 { font-family: var(--display); font-size: 2.4rem; font-weight: 700; margin: 0 0 0.25rem; text-wrap: balance; }
.sub { color: var(--ink-2); margin: 0 0 2rem; max-width: 60ch; }
h2.section-title {
font-family: var(--display); font-size: 1.45rem; font-weight: 700;
margin: 0; padding-left: 0.75rem; border-left: 3px solid var(--accent);
}
.how { font-size: 0.85rem; color: var(--ink-3); margin: 0.35rem 0 0 0.95rem; max-width: 70ch; }
section.block { margin-top: 3rem; }
/* ---- Sticky school header ---- */
.school-bar {
position: sticky; top: 0; z-index: 10;
background: var(--bg); padding: 0.75rem 0; border-bottom: 1px solid var(--border);
display: grid; grid-template-columns: repeat(3, 1fr) auto; gap: 0.75rem; align-items: stretch;
}
.school-chip {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
box-shadow: var(--shadow); padding: 0.55rem 0.75rem; display: flex; gap: 0.55rem; align-items: center;
border-top: 3px solid var(--chip);
}
.school-chip .dot { width: 11px; height: 11px; border-radius: 50%; background: var(--chip); flex: none; }
.school-chip .nm { font-weight: 600; font-size: 0.92rem; line-height: 1.25; }
.school-chip .la { font-size: 0.78rem; color: var(--ink-3); }
.school-chip .x {
margin-left: auto; border: none; background: var(--bg-2); color: var(--ink-3);
border-radius: 50%; width: 22px; height: 22px; cursor: pointer; flex: none; font-size: 0.9rem;
}
.add-school {
border: 1.5px dashed var(--border); border-radius: 8px; background: none;
color: var(--accent); font-weight: 600; padding: 0 1rem; cursor: pointer; font-family: inherit; font-size: 0.9rem;
}
/* ---- Comparison grid rows ---- */
.grid { display: grid; grid-template-columns: 200px repeat(3, 1fr); gap: 0 0.75rem; margin-top: 1.25rem; }
.grid .rowlabel {
font-size: 0.85rem; font-weight: 600; color: var(--ink-2); padding: 0.85rem 0.5rem 0.85rem 0;
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.35rem;
}
.grid .cell { padding: 0.85rem 0.25rem; border-bottom: 1px solid var(--border); font-size: 0.95rem; }
.grid .cell .big { font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.grid .cell .small { display: block; font-size: 0.8rem; color: var(--ink-3); margin-top: 0.1rem; }
.help {
display: inline-flex; width: 15px; height: 15px; border-radius: 50%;
border: 1px solid var(--ink-3); color: var(--ink-3); font-size: 0.65rem;
align-items: center; justify-content: center; cursor: help; flex: none;
}
.chip {
display: inline-block; font-size: 0.75rem; font-weight: 600; border-radius: 999px;
padding: 0.15rem 0.6rem; white-space: nowrap;
}
.chip.good { background: var(--good-bg); color: var(--good-text); }
.chip.warn { background: var(--warn-bg); color: var(--warn-text); }
.chip.bad { background: var(--bad-bg); color: var(--bad-text); }
.chip.neutral { background: var(--neutral-bg); color: var(--neutral-text); }
.ofsted-badge {
display: inline-block; font-weight: 700; border-radius: 6px; padding: 0.25rem 0.7rem; font-size: 0.9rem;
}
/* New-framework report card */
.rc-list { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.2rem; }
.rc-row { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
.rc-row .a { color: var(--ink-2); }
.illus {
display: inline-block; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.03em;
color: var(--accent); border: 1px dashed var(--accent); border-radius: 4px; padding: 0.05rem 0.4rem;
}
.ofsted-1 { background: var(--good-bg); color: var(--good-text); }
.ofsted-2 { background: #e7f0e3; color: #3c6b2f; }
:root[data-theme="dark"] .ofsted-2 { background:#25321f; color:#a5cf94; }
@media (prefers-color-scheme: dark) { .ofsted-2 { background:#25321f; color:#a5cf94; } }
/* ---- Dot strips (signature element) ---- */
.strip-row { margin: 1.1rem 0 1.6rem; }
.strip-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; flex-wrap: wrap; }
.strip-head .t { font-weight: 600; font-size: 0.95rem; }
.strip-head .eng-note { font-size: 0.8rem; color: var(--ink-3); }
.strip { position: relative; height: 34px; margin-top: 0.45rem; }
.strip .track { position: absolute; left: 0; right: 0; top: 15px; height: 4px; border-radius: 2px; background: var(--bg-2); }
.strip .eng-tick { position: absolute; top: 4px; width: 2px; height: 26px; background: var(--eng); }
.strip .eng-lbl { position: absolute; top: -14px; transform: translateX(-50%); font-size: 0.7rem; color: var(--ink-3); white-space: nowrap; }
.strip .pt {
position: absolute; top: 9px; width: 16px; height: 16px; border-radius: 50%;
transform: translateX(-50%); border: 2px solid var(--card); box-shadow: 0 0 0 1px rgba(0,0,0,0.08);
}
.strip .pt-lbl { position: absolute; top: 27px; transform: translateX(-50%); font-size: 0.72rem; font-weight: 600; font-variant-numeric: tabular-nums; }
details.more-measures { margin-top: 0.5rem; border-top: 1px solid var(--border); padding-top: 0.75rem; }
details.more-measures summary { cursor: pointer; font-weight: 600; font-size: 0.88rem; color: var(--accent); }
.strip-note { font-size: 0.78rem; color: var(--ink-3); margin: 0.5rem 0 0; }
.metric-picker { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; flex-wrap: wrap; }
.metric-picker label { font-size: 0.85rem; font-weight: 600; color: var(--ink-2); }
.metric-picker select {
font-family: inherit; font-size: 0.9rem; padding: 0.4rem 0.6rem; border-radius: 8px;
border: 1px solid var(--border); background: var(--card); color: var(--ink); max-width: 100%;
}
.picker-note { font-size: 0.78rem; color: var(--ink-3); }
.legend { display: flex; gap: 1.1rem; flex-wrap: wrap; font-size: 0.82rem; color: var(--ink-2); margin: 0.75rem 0 0; }
.legend .li { display: inline-flex; align-items: center; gap: 0.4rem; }
.legend .sw { width: 10px; height: 10px; border-radius: 50%; }
.legend .engsw { width: 2px; height: 12px; background: var(--eng); }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
box-shadow: var(--shadow); padding: 1.25rem 1.5rem; margin-top: 1rem;
}
/* ---- Sparklines & chart ---- */
.spark { display: block; }
.chart-wrap { overflow-x: auto; }
.covid { fill: var(--bg-2); }
.covid-lbl { font-size: 0.7rem; fill: var(--ink-3); }
.barmini { height: 8px; border-radius: 4px; background: var(--bg-2); overflow: hidden; margin-top: 0.3rem; max-width: 140px; }
.barmini > i { display: block; height: 100%; border-radius: 4px; }
details.explore { margin-top: 1rem; }
details.explore summary {
cursor: pointer; font-weight: 600; color: var(--accent);
padding: 0.85rem 1.1rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px;
}
details.explore[open] summary { border-radius: 8px 8px 0 0; }
details.explore .inner { border: 1px solid var(--border); border-top: none; border-radius: 0 0 8px 8px; background: var(--card); padding: 1.25rem 1.5rem; }
.footnote { font-size: 0.78rem; color: var(--ink-3); margin-top: 2.5rem; border-top: 1px solid var(--border); padding-top: 1rem; max-width: 75ch; }
@media (max-width: 760px) {
.school-bar { grid-template-columns: 1fr; }
.grid { grid-template-columns: 1fr; }
.grid .rowlabel { border-bottom: none; padding-bottom: 0.15rem; background: var(--bg-2); border-radius: 6px; padding: 0.4rem 0.6rem; margin-top: 0.8rem; }
.grid .cell { border-bottom: none; padding: 0.4rem 0.6rem; }
.grid .cell::before { content: attr(data-school); display: block; font-size: 0.72rem; font-weight: 600; color: var(--sc, var(--ink-3)); }
}
</style>
<div class="wrap">
<p class="mock-note"><strong>Mockup — proposed redesign of /compare.</strong> All figures are live production data for three real schools (2024/25 results, 2026/27 admissions round). England averages for test results are official DfE figures; other benchmarks are state-school averages computed from our dataset.</p>
<h1>Compare schools</h1>
<p class="sub">Three schools side by side — inspection results, academics, admissions and community, each anchored against the England average so you can tell at a glance what's typical and what stands out.</p>
<div class="school-bar" aria-label="Schools in this comparison">
<div class="school-chip" style="--chip: var(--s1)">
<span class="dot"></span>
<span><span class="nm">Barclay Primary School</span><br><span class="la">Waltham Forest · Academy</span></span>
<button class="x" aria-label="Remove Barclay Primary School">×</button>
</div>
<div class="school-chip" style="--chip: var(--s2)">
<span class="dot"></span>
<span><span class="nm">Elmhurst Primary School</span><br><span class="la">Newham · Academy</span></span>
<button class="x" aria-label="Remove Elmhurst Primary School">×</button>
</div>
<div class="school-chip" style="--chip: var(--s3)">
<span class="dot"></span>
<span><span class="nm">Plumcroft Primary School</span><br><span class="la">Greenwich · Community school</span></span>
<button class="x" aria-label="Remove Plumcroft Primary School">×</button>
</div>
<button class="add-school">+ Add school</button>
</div>
<!-- ============ AT A GLANCE ============ -->
<section class="block" style="margin-top:2rem">
<h2 class="section-title">At a glance</h2>
<p class="how">The short version — each row below is explained in its own section further down.</p>
<div class="grid">
<div class="rowlabel">Latest Ofsted inspection</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Inspected Oct 2021</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Inspected Oct 2021</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">
<strong style="font-size:0.9rem">Report card</strong> <span class="illus">illustrative</span>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;margin-top:0.3rem">
<span class="chip good">4 areas Strong standard</span>
<span class="chip neutral">2 areas Expected standard</span>
<span class="chip warn">Attendance &amp; behaviour: Attention needed</span>
</div>
<span class="small">Safeguarding met · Nov 2025</span>
</div>
<div class="rowlabel">Children reaching the expected standard <span class="help" title="% of Year 6 pupils reaching the expected standard in reading, writing and maths (2024/25). England average: 62%.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="big" style="color:var(--s1-text)">87%</span> <span class="chip good">Above England average</span><span class="small">England average 62%</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="big" style="color:var(--s2-text)">92%</span> <span class="chip good">Above England average</span><span class="small">England average 62%</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="big" style="color:var(--s3-text)">79%</span> <span class="chip good">Above England average</span><span class="small">England average 62%</span></div>
<div class="rowlabel">Getting a place</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="chip good">97% of first choices offered</span><span class="small">Named on 457 forms · 180 places</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="chip warn">73% of first choices offered</span><span class="small">Named on 342 forms · 120 places</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="chip good">All first choices offered</span><span class="small">Named on 185 forms · 80 places</span></div>
<div class="rowlabel">Size</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">1,273 pupils<span class="small">Much larger than average</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">980 pupils<span class="small">Much larger than average</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">1,056 pupils<span class="small">Much larger than average</span></div>
</div>
</section>
<!-- ============ OFSTED ============ -->
<section class="block">
<h2 class="section-title">Ofsted inspection</h2>
<p class="how">Ofsted is the schools inspectorate. It stopped giving a single overall grade in <strong>September 2024</strong>; inspections between then and November 2025 kept the area-by-area judgements without an overall grade, and from <strong>November 2025</strong> new inspections produce a <strong>report card</strong> rating each area of school life on a five-point scale (Exceptional · Strong standard · Expected standard · Attention needed · Urgent improvement). A report card and an older overall grade aren't directly comparable — Plumcroft's report card below is an <em>illustrative example</em> of the new format, as no school in our dataset has one yet. (Ofsted's "Expected standard" rating is unrelated to the KS2 "expected standard" test measure further down this page.)</p>
<div class="grid">
<div class="rowlabel">Result</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Overall grade (older-style inspection)</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Overall grade (older-style inspection)</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><strong>Report card</strong> <span class="illus">illustrative</span><span class="small">New-style inspection — no overall grade is given</span></div>
<div class="rowlabel">Inspected</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">7 Oct 2021 <span class="chip neutral">4+ years ago</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">6 Oct 2021 <span class="chip neutral">4+ years ago</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">14 Nov 2025</div>
<div class="rowlabel">Judgement detail <span class="help" title="Older-style inspections: one rating per judgement area, where published. New-style inspections: the full report card, one rating per area of school life.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="small">We don't hold area-by-area detail for this inspection — see Barclay's Ofsted page for the full report.</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">
<div class="rc-list">
<div class="rc-row"><span class="a">Quality of education</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Behaviour &amp; attitudes</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Leadership &amp; management</span><span class="chip good">Outstanding</span></div>
</div>
</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">
<div class="rc-list">
<div class="rc-row"><span class="a">Achievement</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Curriculum &amp; teaching</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Attendance &amp; behaviour</span><span class="chip warn">Attention needed</span></div>
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Inclusion</span><span class="chip neutral">Expected standard</span></div>
<div class="rc-row"><span class="a">Leadership &amp; governance</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Early years</span><span class="chip neutral">Expected standard</span></div>
<div class="rc-row"><span class="a">Safeguarding</span><span class="chip good">Met</span></div>
</div>
</div>
<div class="rowlabel">Ofsted page <span class="help" title="Links to the school's page on ofsted.gov.uk, where all its inspection reports are listed.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><a href="https://reports.ofsted.gov.uk/provider/21/138690" style="color:var(--accent)">Barclay's Ofsted page →</a></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><a href="https://reports.ofsted.gov.uk/provider/21/145362" style="color:var(--accent)">Elmhurst's Ofsted page →</a></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><a href="https://reports.ofsted.gov.uk/provider/21/100140" style="color:var(--accent)">Plumcroft's Ofsted page →</a></div>
</div>
</section>
<!-- ============ ACADEMICS ============ -->
<section class="block">
<h2 class="section-title">How children do academically</h2>
<p class="how">Results from national tests and teacher assessments at the end of Year 6 (2024/25) — writing is assessed by teachers, not tested. Each line runs from 0100%; the grey tick marks the England average, so dots to its right are above average.</p>
<div class="card">
<div id="strips"></div>
<details class="more-measures">
<summary>More measures — grammar, punctuation &amp; spelling, science, average scaled scores</summary>
<div id="strips-more"></div>
<p class="strip-note">The strips show the 100120 window of the full 80120 scaled-score range; 100 is the expected standard, and the strip widens if a school averages below it. England ticks for grammar, punctuation &amp; spelling and science aren't in our dataset yet, and the scaled-score England ticks are indicative — official DfE figures for all of these will be loaded before launch.</p>
</details>
<div class="legend" id="strip-legend"></div>
</div>
<div class="grid">
<div class="rowlabel">Trend, 2015/16 to 2024/25 <span class="help" title="% reaching the expected standard in reading, writing and maths each year. Tests were cancelled in 2019/20 and 2020/21 (COVID); 2021/22 school figures aren't in our dataset yet — the line breaks over those years.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><svg class="spark" data-series="52,76,65,87,87,75,87" data-color="s1" width="150" height="40" role="img" aria-label="Barclay trend: variable, most recently 87%"></svg><span class="small">Variable, recently 87%</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><svg class="spark" data-series="80,81,86,88,88,88,92" data-color="s2" width="150" height="40" role="img" aria-label="Elmhurst trend: 80% rising to 92%"></svg><span class="small">Consistently high</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><svg class="spark" data-series="58,64,57,69,62,72,79" data-color="s3" width="150" height="40" role="img" aria-label="Plumcroft trend: 58% rising to 79%"></svg><span class="small">Improving since 2022/23</span></div>
<div class="rowlabel">Children from lower-income families <span class="help" title="% of disadvantaged pupils (eligible for free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. State-school average: 46% (computed from our dataset). Based on smaller pupil groups, so a single pupil can move a school's figure noticeably.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="big" style="font-size:1.1rem">86%</span> <span class="chip good">Well above the 46% state-school average</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="big" style="font-size:1.1rem">93%</span> <span class="chip good">Well above the 46% state-school average</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="big" style="font-size:1.1rem">72%</span> <span class="chip good">Above the 46% state-school average</span></div>
</div>
</section>
<!-- ============ ADMISSIONS ============ -->
<section class="block">
<h2 class="section-title">Getting a place</h2>
<p class="how">From the most recent admissions round (September 2026 entry). "First choice" means families who ranked the school top of their application form — officially a "first preference". Schools never see your ranking: places are decided only by the school's admission criteria, so listing a school lower down never hurts your chances. These are National Offer Day offers — waiting lists and appeals can change the final intake.</p>
<div class="grid">
<div class="rowlabel">Interest in the school <span class="help" title="How many application forms named the school at any preference rank — not the number of families competing head-to-head for a place.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">Named on <span class="big" style="font-size:1.1rem">457</span> forms · <strong>180</strong> places</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">Named on <span class="big" style="font-size:1.1rem">342</span> forms · <strong>120</strong> places</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">Named on <span class="big" style="font-size:1.1rem">185</span> forms · <strong>80</strong> places</div>
<div class="rowlabel">First-choice families offered a place</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><strong>97%</strong><span class="barmini"><i style="width:97%;background:var(--s1)"></i></span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><strong>73%</strong> <span class="chip warn">Over 1 in 4 first choices missed out</span><span class="barmini"><i style="width:73%;background:var(--s2)"></i></span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><strong>100%</strong><span class="barmini"><i style="width:100%;background:var(--s3)"></i></span></div>
<div class="rowlabel">What this means</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="small">Nearly every family who put Barclay first got a place.</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="small">More first-choice applications than places — check the school's admission criteria (for most non-faith primaries, distance decides).</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="small">Every family who put Plumcroft first got a place.</span></div>
</div>
</section>
<!-- ============ COMMUNITY ============ -->
<section class="block">
<h2 class="section-title">Who goes there</h2>
<p class="how">The school's community, from the latest school census (2025/26). England averages are shown for context — there's no "right" number here.</p>
<div class="grid">
<div class="rowlabel">Pupils on roll</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">1,273 <span class="small">1,260 places — at or above capacity</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">980 <span class="small">of 996 places (98% full)</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">1,056 <span class="small">1,050 places — at or above capacity</span></div>
<div class="rowlabel">Girls / boys</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">51% / 49%</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">48% / 52%</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">51% / 49%</div>
<div class="rowlabel">Free school meals <span class="help" title="% of pupils eligible for free school meals — a common measure of how many pupils come from lower-income families. State-school average: 25% (computed from our dataset).">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">26% <span class="chip neutral">About the state-school average</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">25% <span class="chip neutral">About the state-school average</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">30% <span class="chip neutral">A little above average</span></div>
<div class="rowlabel">English as an additional language <span class="help" title="% of pupils whose first language is known or believed to be other than English. State-school average: 22% (computed from our dataset).">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">62%</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">84%</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">20%</div>
<div class="rowlabel">Extra learning support (SEN) <span class="help" title="% of pupils receiving SEN support (not including EHC plans). State-school average: 14% (computed from our dataset). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">6%</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">8%</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">28% <span class="chip neutral">Well above average</span></div>
<div class="rowlabel">Faith character</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">None</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">None</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">None</div>
<div class="rowlabel">Ages · nursery</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">311 · has a nursery</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">311 · has a nursery</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">311 · has a nursery</div>
<div class="rowlabel">Run by</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">Lion Academy Trust</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">New Vision Trust</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">Greenwich council</div>
</div>
</section>
<!-- ============ EXPLORE TRENDS ============ -->
<section class="block">
<h2 class="section-title">Explore trends</h2>
<p class="how">The full year-by-year explorer — every measure from the current compare page lives on here, grouped, each with its England-average line. Three measures are wired up in this mockup; the rest are shown to convey the catalogue.</p>
<details class="explore" open>
<summary>Year-by-year trends, 2015/16 to 2024/25</summary>
<div class="inner chart-wrap">
<div class="metric-picker">
<label for="metric-select">Measure:</label>
<select id="metric-select">
<optgroup label="Expected standard">
<option value="rwm" selected>Reading, writing &amp; maths (combined)</option>
<option value="reading">Reading</option>
<option value="maths">Maths</option>
<option disabled>Writing (teacher assessment)</option>
<option disabled>Grammar, punctuation &amp; spelling</option>
<option disabled>Science</option>
</optgroup>
<optgroup label="Higher standard">
<option disabled>Reading, writing &amp; maths (combined)</option>
<option disabled>Reading · Maths · GPS</option>
<option disabled>Writing — greater depth (teacher assessment)</option>
</optgroup>
<optgroup label="Average scaled scores">
<option disabled>Reading · Maths · GPS</option>
</optgroup>
<optgroup label="Equity">
<option disabled>Disadvantaged pupils — expected standard</option>
</optgroup>
<optgroup label="School context">
<option disabled>Free school meals % · EAL % · SEN support %</option>
</optgroup>
</select>
<span class="picker-note">School lines break where a year isn't in our dataset.</span>
</div>
<svg id="trendchart" width="880" height="300" role="img" aria-label="Line chart: selected measure by year for three schools and England average"></svg>
<div class="legend" id="trend-legend"></div>
</div>
</details>
</section>
<p class="footnote">
Sources: DfE Compare School Performance (KS2 results), Ofsted inspection outcomes, DfE school admissions data, school census — all from datasets SchoolCompare already collects. England averages for test results are the official DfE national figures; benchmarks for free school meals, language, SEN, school size and disadvantaged pupils' results are computed across all state schools in our dataset. Following DfE practice, figures based on 5 or fewer pupils are suppressed and shown as "no data". This is a static mockup: tooltips and "Add school" are illustrative, and Plumcroft's Ofsted report card is a made-up example of the November 2025 format (its real latest inspection is Good, June 2023) — no school in our dataset has a report card yet.
</p>
</div>
<script>
(function () {
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const schools = [
{ name: 'Barclay', color: () => css('--s1'), text: () => css('--s1-text') },
{ name: 'Elmhurst', color: () => css('--s2'), text: () => css('--s2-text') },
{ name: 'Plumcroft', color: () => css('--s3'), text: () => css('--s3-text') },
];
/* ---- Dot strips ---- */
const strips = [
{ label: 'Reading, writing & maths — expected standard', eng: 62, vals: [87, 92, 79] },
{ label: 'Reading', eng: 75, vals: [91, 92, 87] },
{ label: 'Writing', eng: 72, vals: [94, 92, 83] },
{ label: 'Maths', eng: 74, vals: [91, 95, 84] },
{ label: 'Working at a higher standard than expected', eng: 8, vals: [22, 27, 12], tip: 'A high score in the reading and maths tests plus \u201cgreater depth\u201d in teacher-assessed writing.' },
];
// Generalised strip renderer: any domain (percentages or scaled scores),
// England tick optional (omitted when the benchmark isn't in our dataset).
function renderStrips(list, el, firstNote) {
list.forEach((s, si) => {
const min = s.min ?? 0, max = s.max ?? 100, unit = s.unit ?? '%';
const pos = v => (v - min) / (max - min) * 100;
const row = document.createElement('div');
row.className = 'strip-row';
let pts = '';
const sorted = s.vals.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v);
let lastBelow = -Infinity;
const nudge = (max - min) * 0.04;
sorted.forEach(({ v, i }) => {
const above = (v - lastBelow) < nudge;
if (!above) lastBelow = v;
pts += `<span class="pt" style="left:${pos(v)}%;background:${schools[i].color()}" title="${schools[i].name}: ${v}${unit}"></span>`
+ `<span class="pt-lbl" style="left:${pos(v)}%;color:${schools[i].text()};${above ? 'top:-6px' : ''}">${v}</span>`;
});
const eng = s.eng != null
? `<span class="eng-tick" style="left:${pos(s.eng)}%"></span><span class="eng-lbl" style="left:${pos(s.eng)}%">England ${s.eng}${unit}</span>`
: '';
row.innerHTML = `
<div class="strip-head"><span class="t"${s.tip ? ` title="${s.tip}"` : ''}>${s.label}</span>
${si === 0 && firstNote ? `<span class="eng-note">${firstNote}</span>` : ''}</div>
<div class="strip" role="img" aria-label="${s.label}: ${s.eng != null ? 'England average ' + s.eng + unit + ', ' : ''}${s.vals.map((v, i) => schools[i].name + ' ' + v + unit).join(', ')}">
<div class="track"></div>
${eng}
${pts}
</div>`;
el.appendChild(row);
});
}
renderStrips(strips, document.getElementById('strips'), '│ grey tick = England average');
renderStrips([
{ label: 'Grammar, punctuation & spelling — expected standard', eng: null, vals: [86, 94, 82] },
{ label: 'Science — expected standard (teacher-assessed)', eng: null, vals: [95, 91, 83], tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.' },
{ label: 'Average scaled score — reading', eng: 106, vals: [107, 110, 109], min: 100, max: 120, unit: '' },
{ label: 'Average scaled score — maths', eng: 105, vals: [108, 113, 108], min: 100, max: 120, unit: '' },
{ label: 'Average scaled score — grammar, punctuation & spelling', eng: 105, vals: [107, 114, 109], min: 100, max: 120, unit: '' },
], document.getElementById('strips-more'), null);
document.getElementById('strip-legend').innerHTML =
schools.map(s => `<span class="li"><span class="sw" style="background:${s.color()}"></span>${s.name}</span>`).join('')
+ '<span class="li"><span class="engsw"></span>England average</span>';
/* ---- Sparklines (x honours real time: values are 15/1618/19 then 22/2324/25) ---- */
document.querySelectorAll('svg.spark').forEach(svg => {
const vals = svg.dataset.series.split(',').map(Number);
const color = css('--' + svg.dataset.color);
const w = +svg.getAttribute('width'), h = +svg.getAttribute('height');
const min = 40, max = 100;
const sslots = [0,1,2,3,7,8,9];
const x = i => 4 + sslots[i] * (w - 8) / 9;
const y = v => h - 6 - (v - min) * (h - 12) / (max - min);
const seg = idx => idx.map((i, k) => (k ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(vals[i]).toFixed(1)).join(' ');
svg.innerHTML = `<path d="${seg([0,1,2,3])}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`
+ `<path d="${seg([4,5,6])}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`
+ `<circle cx="${x(6)}" cy="${y(vals[6])}" r="3.5" fill="${color}"/>`;
});
/* ---- Trend chart (metric-driven) ----
Tests were cancelled 2019/20-2020/21; lines always break across that band.
Nulls elsewhere are dataset gaps (no school-level 2021/22 rows; no
subject-level 2022/23 rows) and break the lines honestly. England figures
are official DfE (fact_ks2_national_averages); 2015/16 not loaded yet. */
const years = ['2015/16','2016/17','2017/18','2018/19','2021/22','2022/23','2023/24','2024/25'];
const METRICS = {
rwm: {
label: 'Reading, writing & maths - expected standard',
school: { Barclay: [52,76,65,87,null,87,75,87], Elmhurst: [80,81,86,88,null,88,88,92], Plumcroft: [58,64,57,69,null,62,72,79] },
england: [null,61.1,64.3,64.9,58.7,59.5,60.6,62.1],
},
reading: {
label: 'Reading - expected standard',
school: { Barclay: [57,84,76,87,null,null,79,91], Elmhurst: [88,91,92,91,null,null,92,92], Plumcroft: [59,68,72,79,null,null,82,87] },
england: [null,71.6,75.3,73.2,74.6,72.8,74.4,75.0],
},
maths: {
label: 'Maths - expected standard',
school: { Barclay: [81,82,74,90,null,null,88,91], Elmhurst: [96,89,97,97,null,null,96,95], Plumcroft: [80,80,69,90,null,null,84,84] },
england: [null,74.8,75.5,78.7,71.5,73.0,73.2,74.0],
},
};
const schoolColor = { Barclay: () => css('--s1'), Elmhurst: () => css('--s2'), Plumcroft: () => css('--s3') };
const svg = document.getElementById('trendchart');
const W = 880, H = 300, L = 44, R = 48, T = 18, B = 40;
// x positions honour real time: 2019/20 and 2020/21 (cancelled tests) sit between slots 3 and 6
const slots = [0,1,2,3,6,7,8,9]; const maxSlot = 9;
const X = i => L + slots[i] * (W - L - R) / maxSlot;
const Y = v => T + (100 - v) * (H - T - B) / 60; // domain 40..100
// Contiguous non-null runs, additionally split across the covid band (between indices 3 and 4)
function runs(vals) {
const out = []; let cur = [];
vals.forEach((v, i) => {
if (v == null) { if (cur.length) out.push(cur); cur = []; return; }
if (i === 4 && cur.length && cur[cur.length - 1] === 3) { out.push(cur); cur = []; }
cur.push(i);
});
if (cur.length) out.push(cur);
return out;
}
function renderChart(key) {
const m = METRICS[key];
const ink3 = css('--ink-3'), border = css('--border');
let g = '';
const bx1 = X(3) + 14, bx2 = X(4) - 14;
g += `<rect class="covid" x="${bx1}" y="${T}" width="${bx2 - bx1}" height="${H - T - B}" rx="4"/>`;
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 16}" text-anchor="middle">tests cancelled</text>`;
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 30}" text-anchor="middle">2019/20-2020/21</text>`;
for (let v = 40; v <= 100; v += 20) {
g += `<line x1="${L}" y1="${Y(v)}" x2="${W - R}" y2="${Y(v)}" stroke="${border}" stroke-width="1"/>`;
g += `<text x="${L - 8}" y="${Y(v) + 4}" text-anchor="end" font-size="11" fill="${ink3}">${v}%</text>`;
}
years.forEach((yr, i) => {
g += `<text x="${X(i)}" y="${H - B + 20}" text-anchor="middle" font-size="11" fill="${ink3}">${yr}</text>`;
});
const path = (vals, idx) => idx.map((i, k) => (k ? 'L' : 'M') + X(i) + ' ' + Y(vals[i])).join(' ');
runs(m.england).forEach(idx => {
if (idx.length > 1) g += `<path d="${path(m.england, idx)}" fill="none" stroke="${ink3}" stroke-width="1.5" stroke-dasharray="5 4"/>`;
else g += `<circle cx="${X(idx[0])}" cy="${Y(m.england[idx[0]])}" r="2.5" fill="${ink3}"><title>England ${years[idx[0]]}: ${Math.round(m.england[idx[0]])}%</title></circle>`;
});
Object.entries(m.school).forEach(([name, vals]) => {
const color = schoolColor[name]();
runs(vals).forEach(idx => {
if (idx.length > 1) g += `<path d="${path(vals, idx)}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
});
vals.forEach((v, i) => {
if (v == null) return;
g += `<circle cx="${X(i)}" cy="${Y(v)}" r="3.5" fill="${color}" stroke="${css('--card')}" stroke-width="1.5"><title>${name} ${years[i]}: ${v}%</title></circle>`;
});
});
// End labels, pushed apart when schools finish close together
const ends = Object.entries(m.school)
.map(([name, vals]) => ({ v: vals[vals.length - 1], i: vals.length - 1 }))
.filter(e => e.v != null)
.sort((a, b) => a.v - b.v);
let prevY = Infinity;
ends.forEach(e => {
let y = Y(e.v) + 4;
if (prevY - y < 13) y = prevY - 13;
prevY = y;
g += `<text x="${X(e.i) + 8}" y="${y}" font-size="11" font-weight="600" fill="${ink3}">${e.v}%</text>`;
});
svg.innerHTML = g;
svg.setAttribute('aria-label', `Line chart: ${m.label} by year for three schools and England average`);
document.getElementById('trend-legend').innerHTML =
Object.keys(m.school).map(name => `<span class="li"><span class="sw" style="background:${schoolColor[name]()}"></span>${name}</span>`).join('')
+ `<span class="li"><span class="sw" style="background:none;border-top:2px dashed ${ink3};border-radius:0;height:0"></span>England average</span>`;
}
renderChart('rwm');
document.getElementById('metric-select').addEventListener('change', e => renderChart(e.target.value));
// Re-render on theme change so series colors pick up dark-mode tokens
const rerender = () => location.reload();
new MutationObserver(muts => { if (muts.some(m => m.attributeName === 'data-theme')) rerender(); })
.observe(document.documentElement, { attributes: true });
})();
</script>
@@ -0,0 +1,437 @@
<title>Compare screen — mobile mockup</title>
<style>
:root {
--bg: #faf7f2; --bg-2: #f3ede4; --card: #ffffff;
--ink: #1a1612; --ink-2: #5c564d; --ink-3: #6d685f;
--border: #e5dfd5; --accent: #b04a2e; --accent-bg: rgba(224,114,86,0.12);
--s1: #e07256; --s1-text: #b04a2e;
--s2: #00949b; --s2-text: #006a70;
--s3: #8664c9; --s3-text: #6a4bab;
--eng: #6d685f;
--good-bg: #e3efe6; --good-text: #1a6b34;
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
--neutral-bg: #efeae1; --neutral-text: #5c564d;
--shadow: 0 2px 8px rgba(26,22,18,0.06);
--display: 'Playfair Display', Georgia, 'Times New Roman', serif;
--body: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
--s1: #f08b6e; --s1-text: #f4a58e;
--s2: #2fb8ae; --s2-text: #5fd0c8;
--s3: #a98fe0; --s3-text: #c0abec;
--eng: #9a927f;
--good-bg: #1e3325; --good-text: #7fd39a;
--warn-bg: #38301a; --warn-text: #e4c268;
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
--shadow: 0 2px 8px rgba(0,0,0,0.35);
}
}
:root[data-theme="dark"] {
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
--s1: #f08b6e; --s1-text: #f4a58e;
--s2: #2fb8ae; --s2-text: #5fd0c8;
--s3: #a98fe0; --s3-text: #c0abec;
--eng: #9a927f;
--good-bg: #1e3325; --good-text: #7fd39a;
--warn-bg: #38301a; --warn-text: #e4c268;
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
--shadow: 0 2px 8px rgba(0,0,0,0.35);
}
:root[data-theme="light"] {
--bg: #faf7f2; --bg-2: #f3ede4; --card: #ffffff;
--ink: #1a1612; --ink-2: #5c564d; --ink-3: #6d685f;
--border: #e5dfd5; --accent: #b04a2e; --accent-bg: rgba(224,114,86,0.12);
--s1: #e07256; --s1-text: #b04a2e;
--s2: #00949b; --s2-text: #006a70;
--s3: #8664c9; --s3-text: #6a4bab;
--eng: #6d685f;
--good-bg: #e3efe6; --good-text: #1a6b34;
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
--neutral-bg: #efeae1; --neutral-text: #5c564d;
--shadow: 0 2px 8px rgba(26,22,18,0.06);
}
body { background: var(--bg-2); color: var(--ink); font-family: var(--body); line-height: 1.45; }
/* Phone canvas: true mobile width, framed on larger screens */
.phone { max-width: 400px; margin: 0 auto; background: var(--bg); min-height: 100vh; }
@media (min-width: 480px) {
.phone { margin: 1.5rem auto; border: 1px solid var(--border); border-radius: 24px; overflow: hidden; box-shadow: var(--shadow); }
}
.inner { padding: 1rem 0.9rem 3rem; }
.mock-note {
background: var(--accent-bg); border: 1px solid var(--border); border-radius: 8px;
padding: 0.5rem 0.75rem; font-size: 0.78rem; color: var(--ink-2); margin-bottom: 1rem;
}
.mock-note strong { color: var(--accent); }
h1 { font-family: var(--display); font-size: 1.7rem; font-weight: 700; margin: 0 0 0.2rem; }
.sub { color: var(--ink-2); margin: 0 0 1rem; font-size: 0.88rem; }
/* Sticky school chip bar — horizontal scroll */
.chipbar {
position: sticky; top: 0; z-index: 10; background: var(--bg);
display: flex; gap: 0.5rem; overflow-x: auto; padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border); margin: 0 -0.9rem 1rem; -webkit-overflow-scrolling: touch;
}
.chipbar::-webkit-scrollbar { display: none; }
.schip {
flex: none; display: inline-flex; align-items: center; gap: 0.4rem;
background: var(--card); border: 1px solid var(--border); border-radius: 999px;
padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 600; white-space: nowrap;
}
.schip .dot { width: 9px; height: 9px; border-radius: 50%; }
.schip .x { border: none; background: var(--bg-2); color: var(--ink-3); border-radius: 50%; width: 17px; height: 17px; font-size: 0.7rem; line-height: 1; }
.schip.add { color: var(--accent); border-style: dashed; }
h2.section-title {
font-family: var(--display); font-size: 1.2rem; font-weight: 700;
margin: 1.6rem 0 0; padding-left: 0.6rem; border-left: 3px solid var(--accent);
}
.how { font-size: 0.78rem; color: var(--ink-3); margin: 0.3rem 0 0.75rem 0.8rem; }
/* Measure-first block: one measure, all schools under it */
.measure { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 0.75rem 0.85rem; margin-bottom: 0.6rem; box-shadow: var(--shadow); }
.measure .mt { font-size: 0.85rem; font-weight: 600; display: flex; align-items: center; gap: 0.35rem; }
.measure .mh { font-size: 0.75rem; color: var(--ink-3); margin-top: 0.05rem; }
.srow { display: flex; align-items: center; gap: 0.5rem; padding: 0.45rem 0; border-top: 1px solid var(--border); margin-top: 0.45rem; flex-wrap: wrap; }
.srow:first-of-type { border-top: none; }
.srow .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
.srow .sn { font-size: 0.8rem; font-weight: 600; color: var(--ink-2); width: 4.6rem; flex: none; }
.srow .val { font-weight: 700; font-variant-numeric: tabular-nums; font-size: 0.95rem; }
.srow .note { font-size: 0.74rem; color: var(--ink-3); flex-basis: 100%; padding-left: 1.1rem; margin-top: -0.15rem; }
.chip { display: inline-block; font-size: 0.68rem; font-weight: 600; border-radius: 999px; padding: 0.12rem 0.5rem; white-space: nowrap; }
.chip.good { background: var(--good-bg); color: var(--good-text); }
.chip.warn { background: var(--warn-bg); color: var(--warn-text); }
.chip.neutral { background: var(--neutral-bg); color: var(--neutral-text); }
.ofsted-badge { display: inline-block; font-weight: 700; border-radius: 6px; padding: 0.14rem 0.5rem; font-size: 0.78rem; }
.ofsted-1 { background: var(--good-bg); color: var(--good-text); }
.illus { display: inline-block; font-size: 0.62rem; font-weight: 600; color: var(--accent); border: 1px dashed var(--accent); border-radius: 4px; padding: 0 0.3rem; }
.help { display: inline-flex; width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--ink-3); color: var(--ink-3); font-size: 0.6rem; align-items: center; justify-content: center; flex: none; }
/* Dot strips */
.strip-row { margin: 0.9rem 0 1.3rem; }
.strip-row .t { font-weight: 600; font-size: 0.82rem; }
.strip { position: relative; height: 32px; margin-top: 0.4rem; }
.strip .track { position: absolute; left: 0; right: 0; top: 14px; height: 4px; border-radius: 2px; background: var(--bg-2); }
.strip .eng-tick { position: absolute; top: 4px; width: 2px; height: 24px; background: var(--eng); }
.strip .eng-lbl { position: absolute; top: -12px; transform: translateX(-50%); font-size: 0.62rem; color: var(--ink-3); white-space: nowrap; }
.strip .pt { position: absolute; top: 9px; width: 14px; height: 14px; border-radius: 50%; transform: translateX(-50%); border: 2px solid var(--card); box-shadow: 0 0 0 1px rgba(0,0,0,0.08); }
.strip .pt-lbl { position: absolute; top: 25px; transform: translateX(-50%); font-size: 0.64rem; font-weight: 600; }
.legend { display: flex; gap: 0.8rem; flex-wrap: wrap; font-size: 0.72rem; color: var(--ink-2); margin-top: 0.4rem; }
.legend .li { display: inline-flex; align-items: center; gap: 0.3rem; }
.legend .sw { width: 9px; height: 9px; border-radius: 50%; }
.legend .engsw { width: 2px; height: 11px; background: var(--eng); }
.rc-list { display: flex; flex-direction: column; gap: 0.25rem; width: 100%; padding-left: 1.1rem; }
.rc-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.74rem; }
.rc-row .a { color: var(--ink-2); }
details.more-measures { margin-bottom: 0.6rem; }
details.more-measures summary { cursor: pointer; font-weight: 600; font-size: 0.82rem; color: var(--accent); padding: 0.2rem 0.2rem 0.5rem; }
.strip-note { font-size: 0.72rem; color: var(--ink-3); margin: 0.4rem 0.2rem 0; }
.metric-picker { margin-bottom: 0.6rem; }
.metric-picker select { width: 100%; font-family: inherit; font-size: 0.85rem; padding: 0.45rem 0.6rem; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: var(--ink); }
.chart-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 0.75rem; }
.swipe-hint { font-size: 0.7rem; color: var(--ink-3); text-align: center; margin-top: 0.3rem; }
.covid { fill: var(--bg-2); }
.covid-lbl { font-size: 0.6rem; fill: var(--ink-3); }
.footnote { font-size: 0.7rem; color: var(--ink-3); margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
</style>
<div class="phone"><div class="inner">
<p class="mock-note"><strong>Mobile mockup — proposed /compare.</strong> Mobile-first layout: measures stack vertically with all schools under each, so nothing needs horizontal swiping. Same live data as the desktop mockup.</p>
<h1>Compare schools</h1>
<p class="sub">Anchored against the England average — the grey tick — so you can tell what's typical at a glance.</p>
<div class="chipbar" aria-label="Schools in this comparison">
<span class="schip"><span class="dot" style="background:var(--s1)"></span>Barclay <button class="x" aria-label="Remove Barclay">×</button></span>
<span class="schip"><span class="dot" style="background:var(--s2)"></span>Elmhurst <button class="x" aria-label="Remove Elmhurst">×</button></span>
<span class="schip"><span class="dot" style="background:var(--s3)"></span>Plumcroft <button class="x" aria-label="Remove Plumcroft">×</button></span>
<span class="schip add">+ Add</span>
</div>
<h2 class="section-title">At a glance</h2>
<p class="how">The short version — each measure is explained in its own section below.</p>
<div class="measure">
<div class="mt">Latest Ofsted inspection</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="ofsted-badge ofsted-1">Outstanding</span><span class="note">Older-style inspection, Oct 2021</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="ofsted-badge ofsted-1">Outstanding</span><span class="note">Older-style inspection, Oct 2021</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span><span class="chip good">4 areas Strong standard</span> <span class="chip neutral">2 areas Expected</span> <span class="chip warn">Attendance &amp; behaviour: Attention needed</span> <span class="illus">illustrative</span></span><span class="note">New-style report card, Nov 2025 · safeguarding met · full detail in the Ofsted section below</span></div>
</div>
<div class="measure">
<div class="mt">Children reaching the expected standard <span class="help" title="% of Year 6 pupils reaching the expected standard in reading, writing and maths (2024/25).">?</span></div>
<div class="mh">England average: 62%</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val" style="color:var(--s1-text)">87%</span> <span class="chip good">Above average</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val" style="color:var(--s2-text)">92%</span> <span class="chip good">Above average</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val" style="color:var(--s3-text)">79%</span> <span class="chip good">Above average</span></div>
</div>
<div class="measure">
<div class="mt">Getting a place</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="chip good">97% of first choices offered</span><span class="note">Named on 457 forms · 180 places</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="chip warn">73% of first choices offered</span><span class="note">Named on 342 forms · 120 places</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="chip good">All first choices offered</span><span class="note">Named on 185 forms · 80 places</span></div>
</div>
<h2 class="section-title">Ofsted inspection</h2>
<p class="how">Ofsted stopped giving a single overall grade in September 2024 (inspections until November 2025 kept the area-by-area judgements); from November 2025 new inspections produce a report card rating each area of school life (Exceptional · Strong standard · Expected standard · Attention needed · Urgent improvement). A report card and an older grade aren't directly comparable. Ofsted's "Expected standard" rating is unrelated to the KS2 test measure below.</p>
<div class="measure">
<div class="mt">Latest inspection</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="ofsted-badge ofsted-1">Outstanding</span><span class="chip neutral">4+ years ago</span><span class="note">7 Oct 2021 · we don't hold area-by-area detail for this inspection · <a href="https://reports.ofsted.gov.uk/provider/21/138690" style="color:var(--accent)">Ofsted page →</a></span></div>
<div class="srow" style="align-items:flex-start"><span class="dot" style="background:var(--s2);margin-top:4px"></span><span class="sn">Elmhurst</span><span><span class="ofsted-badge ofsted-1">Outstanding</span> <span class="chip neutral">4+ years ago</span> <span style="font-size:0.72rem;color:var(--ink-3)">6 Oct 2021</span></span>
<div class="rc-list">
<div class="rc-row"><span class="a">Quality of education</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Behaviour &amp; attitudes</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Leadership &amp; management</span><span class="chip good">Outstanding</span></div>
</div>
<span class="note"><a href="https://reports.ofsted.gov.uk/provider/21/145362" style="color:var(--accent)">Ofsted page →</a></span>
</div>
<div class="srow" style="align-items:flex-start"><span class="dot" style="background:var(--s3);margin-top:4px"></span><span class="sn">Plumcroft</span><span><strong style="font-size:0.85rem">Report card</strong> <span class="illus">illustrative</span> <span style="font-size:0.72rem;color:var(--ink-3)">14 Nov 2025</span></span>
<div class="rc-list">
<div class="rc-row"><span class="a">Achievement</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Curriculum &amp; teaching</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Attendance &amp; behaviour</span><span class="chip warn">Attention needed</span></div>
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Inclusion</span><span class="chip neutral">Expected standard</span></div>
<div class="rc-row"><span class="a">Leadership &amp; governance</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Early years</span><span class="chip neutral">Expected standard</span></div>
<div class="rc-row"><span class="a">Safeguarding</span><span class="chip good">Met</span></div>
</div>
<span class="note"><a href="https://reports.ofsted.gov.uk/provider/21/100140" style="color:var(--accent)">Ofsted page →</a></span>
</div>
</div>
<h2 class="section-title">How children do academically</h2>
<p class="how">End of Year 6 national tests and teacher assessments (2024/25) — writing is teacher-assessed. Each line runs 0100%; the grey tick is the England average.</p>
<div class="measure" id="strips"></div>
<details class="more-measures">
<summary>More measures — grammar, punctuation &amp; spelling, science, scaled scores</summary>
<div class="measure" id="strips-more" style="margin-top:0.5rem"></div>
<p class="strip-note">Strips show the 100120 window of the full 80120 scaled-score range; 100 is the expected standard (the strip widens if a school averages below it). England ticks for GPS and science aren't in our dataset yet, and the scaled-score ticks are indicative — official DfE figures will be loaded before launch.</p>
</details>
<div class="legend" id="strip-legend" style="padding:0 0.2rem 0"></div>
<div class="measure" style="margin-top:0.9rem">
<div class="mt">Children from lower-income families <span class="help" title="% of disadvantaged pupils (free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. State-school average: 46% (computed from our dataset). Small pupil groups — single pupils can move a school's figure noticeably.">?</span></div>
<div class="mh">State-school average: 46%</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">86%</span> <span class="chip good">Well above average</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">93%</span> <span class="chip good">Well above average</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">72%</span> <span class="chip good">Above average</span></div>
</div>
<h2 class="section-title">Getting a place</h2>
<p class="how">September 2026 entry. "First choice" = families who ranked the school top of their form (officially a "first preference"). Schools never see your ranking — places go by the admission criteria alone. Figures are National Offer Day offers; waiting lists and appeals can change the final intake.</p>
<div class="measure">
<div class="mt">First-choice families offered a place</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">97%</span><span class="note">Named on 457 forms · 180 places</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">73%</span> <span class="chip warn">Over 1 in 4 missed out</span><span class="note">Named on 342 forms · 120 places — check the school's admission criteria (for most non-faith primaries, distance decides)</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">100%</span><span class="note">Named on 185 forms · 80 places · every first choice offered</span></div>
</div>
<h2 class="section-title">Who goes there</h2>
<p class="how">From the latest school census (2025/26). No "right" numbers here — just context.</p>
<div class="measure">
<div class="mt">Pupils on roll</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">1,273</span><span class="note">At or above capacity · much larger than average · girls 51% / boys 49%</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">980</span><span class="note">98% full · much larger than average · girls 48% / boys 52%</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">1,056</span><span class="note">At or above capacity · much larger than average · girls 51% / boys 49%</span></div>
</div>
<div class="measure">
<div class="mt">Free school meals <span class="help" title="% eligible for free school meals. State-school average: 25% (computed from our dataset).">?</span></div>
<div class="mh">State-school average: 25% (our dataset)</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">26%</span> <span class="chip neutral">About average</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">25%</span> <span class="chip neutral">About average</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">30%</span> <span class="chip neutral">A little above</span></div>
</div>
<div class="measure">
<div class="mt">English as an additional language · extra learning support (SEN) <span class="help" title="SEN = pupils receiving SEN support, not including EHC plans. State-school average: ≈14% (computed from our dataset). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign.">?</span></div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span style="font-size:0.85rem">EAL 62% · SEN 6%</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span style="font-size:0.85rem">EAL 84% · SEN 8%</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span style="font-size:0.85rem">EAL 20% · SEN 28% <span class="chip neutral">SEN well above avg</span></span></div>
</div>
<div class="measure">
<div class="mt">Basics</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span style="font-size:0.85rem">Ages 311 · nursery · no faith · Lion Academy Trust</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span style="font-size:0.85rem">Ages 311 · nursery · no faith · New Vision Trust</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span style="font-size:0.85rem">Ages 311 · nursery · no faith · Greenwich council</span></div>
</div>
<h2 class="section-title">Explore trends</h2>
<p class="how">Every measure from the current compare page lives on here, grouped. Three are wired up in this mockup. School lines break where a year isn't in our dataset.</p>
<div class="metric-picker">
<select id="metric-select" aria-label="Measure">
<optgroup label="Expected standard">
<option value="rwm" selected>Reading, writing &amp; maths (combined)</option>
<option value="reading">Reading</option>
<option value="maths">Maths</option>
<option disabled>Writing (TA) · GPS · Science (TA)</option>
</optgroup>
<optgroup label="Higher standard"><option disabled>RWM · Reading · Maths · GPS</option><option disabled>Writing — greater depth (TA)</option></optgroup>
<optgroup label="Average scaled scores"><option disabled>Reading · Maths · GPS</option></optgroup>
<optgroup label="Equity"><option disabled>Disadvantaged pupils — expected standard</option></optgroup>
<optgroup label="School context"><option disabled>FSM % · EAL % · SEN support %</option></optgroup>
</select>
</div>
<div class="chart-wrap">
<svg id="trendchart" width="620" height="240" role="img" aria-label="Line chart: selected measure by year for three schools and England average"></svg>
<div class="legend" id="trend-legend"></div>
</div>
<p class="swipe-hint">← swipe the chart →</p>
<p class="footnote">
Sources: DfE Compare School Performance, Ofsted inspection outcomes, DfE admissions data, school census — all from datasets SchoolCompare already collects. England averages for test results are official DfE figures; FSM, language, SEN, size and disadvantaged-pupil benchmarks are computed across state schools in our dataset. Plumcroft's Ofsted report card is a made-up example of the November 2025 format (its real latest inspection is Good, June 2023). Following DfE practice, figures based on 5 or fewer pupils are suppressed and shown as "no data". Static mockup — tooltips and "+ Add" are illustrative.
</p>
</div></div>
<script>
(function () {
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const schools = [
{ name: 'Barclay', color: () => css('--s1'), text: () => css('--s1-text') },
{ name: 'Elmhurst', color: () => css('--s2'), text: () => css('--s2-text') },
{ name: 'Plumcroft', color: () => css('--s3'), text: () => css('--s3-text') },
];
const strips = [
{ label: 'Reading, writing & maths', eng: 62, vals: [87, 92, 79] },
{ label: 'Reading', eng: 75, vals: [91, 92, 87] },
{ label: 'Writing', eng: 72, vals: [94, 92, 83] },
{ label: 'Maths', eng: 74, vals: [91, 95, 84] },
{ label: 'Higher standard', eng: 8, vals: [22, 27, 12], tip: 'A high score in the reading and maths tests plus \u201cgreater depth\u201d in teacher-assessed writing.' },
];
function renderStrips(list, el) {
list.forEach((s) => {
const min = s.min ?? 0, max = s.max ?? 100, unit = s.unit ?? '%';
const pos = v => (v - min) / (max - min) * 100;
const row = document.createElement('div');
row.className = 'strip-row';
let pts = '';
const sorted = s.vals.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v);
let lastBelow = -Infinity;
const nudge = (max - min) * 0.08;
sorted.forEach(({ v, i }) => {
const above = (v - lastBelow) < nudge;
if (!above) lastBelow = v;
pts += `<span class="pt" style="left:${pos(v)}%;background:${schools[i].color()}" title="${schools[i].name}: ${v}${unit}"></span>`
+ `<span class="pt-lbl" style="left:${pos(v)}%;color:${schools[i].text()};${above ? 'top:-6px' : ''}">${v}</span>`;
});
const eng = s.eng != null
? `<span class="eng-tick" style="left:${pos(s.eng)}%"></span><span class="eng-lbl" style="left:${pos(s.eng)}%">Eng ${s.eng}${unit}</span>`
: '';
row.innerHTML = `
<div class="t"${s.tip ? ` title="${s.tip}"` : ''}>${s.label}</div>
<div class="strip" role="img" aria-label="${s.label}: ${s.eng != null ? 'England average ' + s.eng + unit + ', ' : ''}${s.vals.map((v, i) => schools[i].name + ' ' + v + unit).join(', ')}">
<div class="track"></div>
${eng}
${pts}
</div>`;
el.appendChild(row);
});
}
renderStrips(strips, document.getElementById('strips'));
renderStrips([
{ label: 'Grammar, punctuation & spelling', eng: null, vals: [86, 94, 82] },
{ label: 'Science (teacher-assessed)', eng: null, vals: [95, 91, 83], tip: 'Teacher-assessed, like writing — no KS2 science test since 2009; comparisons are indicative.' },
{ label: 'Avg scaled score — reading', eng: 106, vals: [107, 110, 109], min: 100, max: 120, unit: '' },
{ label: 'Avg scaled score — maths', eng: 105, vals: [108, 113, 108], min: 100, max: 120, unit: '' },
{ label: 'Avg scaled score — GPS', eng: 105, vals: [107, 114, 109], min: 100, max: 120, unit: '' },
], document.getElementById('strips-more'));
document.getElementById('strip-legend').innerHTML =
schools.map(s => `<span class="li"><span class="sw" style="background:${s.color()}"></span>${s.name}</span>`).join('')
+ '<span class="li"><span class="engsw"></span>England average</span>';
/* Metric-driven trend chart. Lines always break across the covid band
(2019/20-2020/21, cancelled tests); other nulls are dataset gaps. */
const years = ['2015/16','2016/17','2017/18','2018/19','2021/22','2022/23','2023/24','2024/25'];
const METRICS = {
rwm: { school: { Barclay: [52,76,65,87,null,87,75,87], Elmhurst: [80,81,86,88,null,88,88,92], Plumcroft: [58,64,57,69,null,62,72,79] },
england: [null,61.1,64.3,64.9,58.7,59.5,60.6,62.1] },
reading: { school: { Barclay: [57,84,76,87,null,null,79,91], Elmhurst: [88,91,92,91,null,null,92,92], Plumcroft: [59,68,72,79,null,null,82,87] },
england: [null,71.6,75.3,73.2,74.6,72.8,74.4,75.0] },
maths: { school: { Barclay: [81,82,74,90,null,null,88,91], Elmhurst: [96,89,97,97,null,null,96,95], Plumcroft: [80,80,69,90,null,null,84,84] },
england: [null,74.8,75.5,78.7,71.5,73.0,73.2,74.0] },
};
const schoolColor = { Barclay: () => css('--s1'), Elmhurst: () => css('--s2'), Plumcroft: () => css('--s3') };
const svg = document.getElementById('trendchart');
const W = 620, H = 240, L = 38, R = 44, T = 14, B = 34;
const slots = [0,1,2,3,6,7,8,9]; const maxSlot = 9;
const X = i => L + slots[i] * (W - L - R) / maxSlot;
const Y = v => T + (100 - v) * (H - T - B) / 60;
function runs(vals) {
const out = []; let cur = [];
vals.forEach((v, i) => {
if (v == null) { if (cur.length) out.push(cur); cur = []; return; }
if (i === 4 && cur.length && cur[cur.length - 1] === 3) { out.push(cur); cur = []; }
cur.push(i);
});
if (cur.length) out.push(cur);
return out;
}
function renderChart(key) {
const m = METRICS[key];
const ink3 = css('--ink-3'), border = css('--border');
let g = '';
const bx1 = X(3) + 12, bx2 = X(4) - 12;
g += `<rect class="covid" x="${bx1}" y="${T}" width="${bx2 - bx1}" height="${H - T - B}" rx="4"/>`;
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 14}" text-anchor="middle">tests cancelled</text>`;
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 26}" text-anchor="middle">'19/20-'20/21</text>`;
for (let v = 40; v <= 100; v += 20) {
g += `<line x1="${L}" y1="${Y(v)}" x2="${W - R}" y2="${Y(v)}" stroke="${border}" stroke-width="1"/>`;
g += `<text x="${L - 6}" y="${Y(v) + 4}" text-anchor="end" font-size="10" fill="${ink3}">${v}%</text>`;
}
years.forEach((yr, i) => {
g += `<text x="${X(i)}" y="${H - B + 18}" text-anchor="middle" font-size="9.5" fill="${ink3}">${yr}</text>`;
});
const path = (vals, idx) => idx.map((i, k) => (k ? 'L' : 'M') + X(i) + ' ' + Y(vals[i])).join(' ');
runs(m.england).forEach(idx => {
if (idx.length > 1) g += `<path d="${path(m.england, idx)}" fill="none" stroke="${ink3}" stroke-width="1.5" stroke-dasharray="5 4"/>`;
else g += `<circle cx="${X(idx[0])}" cy="${Y(m.england[idx[0]])}" r="2" fill="${ink3}"><title>England ${years[idx[0]]}: ${Math.round(m.england[idx[0]])}%</title></circle>`;
});
Object.entries(m.school).forEach(([name, vals]) => {
const color = schoolColor[name]();
runs(vals).forEach(idx => {
if (idx.length > 1) g += `<path d="${path(vals, idx)}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
});
vals.forEach((v, i) => {
if (v == null) return;
g += `<circle cx="${X(i)}" cy="${Y(v)}" r="3" fill="${color}" stroke="${css('--card')}" stroke-width="1.5"><title>${name} ${years[i]}: ${v}%</title></circle>`;
});
});
const ends = Object.entries(m.school)
.map(([name, vals]) => ({ v: vals[vals.length - 1], i: vals.length - 1 }))
.filter(e => e.v != null)
.sort((a, b) => a.v - b.v);
let prevY = Infinity;
ends.forEach(e => {
let y = Y(e.v) + 3.5;
if (prevY - y < 12) y = prevY - 12;
prevY = y;
g += `<text x="${X(e.i) + 7}" y="${y}" font-size="10" font-weight="600" fill="${ink3}">${e.v}%</text>`;
});
svg.innerHTML = g;
document.getElementById('trend-legend').innerHTML =
Object.keys(m.school).map(name => `<span class="li"><span class="sw" style="background:${schoolColor[name]()}"></span>${name}</span>`).join('')
+ `<span class="li"><span class="sw" style="background:none;border-top:2px dashed ${ink3};border-radius:0;height:0"></span>England avg</span>`;
}
renderChart('rwm');
document.getElementById('metric-select').addEventListener('change', e => renderChart(e.target.value));
new MutationObserver(muts => { if (muts.some(m => m.attributeName === 'data-theme')) location.reload(); })
.observe(document.documentElement, { attributes: true });
})();
</script>
+354 -7
View File
@@ -19,12 +19,56 @@ function schoolLinks(page: Page) {
return page.locator('a[href^="/school/"]'); return page.locator('a[href^="/school/"]');
} }
/**
* Two URNs guaranteed to be pure-primary (same phase). The compare page's
* phase tabs split all-through schools (which carry KS4 data) onto the
* secondary tab, so picking two arbitrary "primary" search hits can land
* them on different tabs where only the active one renders. Selecting via
* the API by exact phase keeps both on the same tab. Data-invariant: uses
* whatever primaries the environment holds.
*/
async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
const res = await page.request.get('/api/schools?search=primary&per_page=50');
expect(res.ok()).toBeTruthy();
const body = await res.json();
const urns: string[] = (body.schools ?? [])
.filter((s: { phase?: string; rwm_expected_pct?: number | null }) =>
s.phase === 'Primary' && s.rwm_expected_pct != null,
)
.map((s: { urn: number }) => String(s.urn));
expect(urns.length).toBeGreaterThanOrEqual(2);
return [urns[0], urns[1]];
}
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
const res = await page.request.get('/api/schools?search=school&per_page=100');
expect(res.ok()).toBeTruthy();
const body = await res.json();
const urns: string[] = (body.schools ?? [])
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
s.phase === 'Secondary' && s.attainment_8_score != null,
)
.map((s: { urn: number }) => String(s.urn));
expect(urns.length).toBeGreaterThanOrEqual(2);
return [urns[0], urns[1]];
}
test('home page loads with hero search', async ({ page }) => { test('home page loads with hero search', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await expect(page.locator('h1').first()).toBeVisible(); await expect(page.locator('h1').first()).toBeVisible();
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible(); await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
}); });
test('home hero offers a "use my location" shortcut beside the search box', async ({ page }) => {
await page.goto('/');
// The geolocation shortcut lives inside the hero search card, right under the
// search input — not in a separate strip further down the page.
const searchInput = page.getByPlaceholder('School name or postcode').first();
await expect(searchInput).toBeVisible();
const nearMe = page.getByRole('button', { name: /use my location/i });
await expect(nearMe).toBeVisible();
});
test('searching by name returns school results', async ({ page }) => { test('searching by name returns school results', async ({ page }) => {
await searchByName(page, 'primary'); await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
@@ -36,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();
@@ -50,6 +121,114 @@ 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 }) => {
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
// serialization — which the frontend rendered as a 404 on every such SEO
// landing page. Find one via the search API (year === null marks "no
// performance rows") and assert its page renders.
const candidates: number[] = [];
for (const q of ['post 16', 'specialist college', 'sixth form']) {
const resp = await page.request.get(
`/api/schools?search=${encodeURIComponent(q)}&per_page=20`
);
if (!resp.ok()) continue;
const body = await resp.json();
for (const s of body.schools ?? []) {
if (s.year === null && s.urn) candidates.push(s.urn);
}
if (candidates.length) break;
}
test.skip(candidates.length === 0, 'no results-less school in this dataset');
const detail = await page.request.get(`/api/schools/${candidates[0]}`);
expect(detail.status(), 'detail API must not 500 for a results-less school').toBe(200);
await page.goto(`/school/${candidates[0]}`);
await page.waitForURL(/\/school\/\d+-/); // redirected to canonical slug
await expect(page.locator('h1').first()).toBeVisible();
});
test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => { test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => {
// iOS Safari has no Element.requestFullscreen; the map must fall back to a // iOS Safari has no Element.requestFullscreen; the map must fall back to a
// CSS overlay. Simulate that by removing the API before any page script runs. // CSS overlay. Simulate that by removing the API before any page script runs.
@@ -75,20 +254,188 @@ test('school hero map opens fullscreen on mobile without the Fullscreen API', as
await expect(openMap).toBeVisible(); await expect(openMap).toBeVisible();
}); });
test('comparing two schools shows both side by side', async ({ page }) => { test('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
// Collect two school URNs from search results, then load the share URL // Same iOS gap as the hero map: no Element.requestFullscreen, so the results
// map's fullscreen button must fall back to a CSS overlay.
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
// @ts-expect-error deliberate API removal
delete Element.prototype.requestFullscreen;
});
await searchByName(page, 'B1 1BB');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
// Switch to the map view, then open the map fullscreen.
await page.getByRole('button', { name: 'Map', exact: true }).click();
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
await expect(openFs).toBeVisible({ timeout: 15_000 });
await openFs.click();
// The button flips to its exit state once the overlay is up.
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
await expect(exitFs).toBeVisible();
await exitFs.click();
await expect(openFs).toBeVisible();
});
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
// Two same-phase (pure primary) schools so both stay on one tab.
const [urn0, urn1] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${urn0},${urn1}`);
// Both schools' detail links should render in the comparison view
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator(`a[href*="${urn1}"]`).first()).toBeVisible();
// The parent-first sections render in order (data-invariant: headings only)
for (const heading of [
'At a glance',
'Ofsted inspection',
/How (children|students) do academically/,
'Who goes there',
'Explore trends',
]) {
await expect(
page.getByRole('heading', { name: heading }).first(),
).toBeVisible({ timeout: 15_000 });
}
// Every number gets an anchor: at least one England-average tick or label
await expect(page.getByText(/England \d+/).first()).toBeVisible();
// Desktop: the sticky school bar shares the sections' grid template
// (200px label rail + one column per school) so chips align with the
// columns they label.
const barTemplate = await page
.locator('[aria-label="Schools in this comparison"]')
.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
expect(barTemplate).toMatch(/^200px /);
// ...and its label rail carries the comparison caption.
await expect(page.getByText(/^\d+ (primary|secondary) schools?$/)).toBeVisible();
// Ofsted linkout goes to the school's provider page, never a report deep-link
const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first();
await expect(ofstedLink).toBeVisible();
expect(await ofstedLink.getAttribute('href')).toMatch(
/reports\.ofsted\.gov\.uk\/provider\/21\/\d+/
);
// A school never shows both an overall-grade badge AND report-card detail:
// "Report card" implies "no overall grade is given" copy is present too.
const reportCards = await page.getByText('Report card', { exact: true }).count();
if (reportCards > 0) {
await expect(page.getByText(/no overall grade/i).first()).toBeVisible();
}
});
test('comparing two secondary schools renders the secondary sections', async ({ page }) => {
const [urn0, urn1] = await twoSecondaryUrns(page);
await page.goto(`/compare?urns=${urn0},${urn1}`);
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
// The parent-first sections must render — this page was completely blank
// for all-secondary baskets (expert review must-fix #1).
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole('heading', { name: 'Ofsted inspection' }).first()).toBeVisible();
// A KS4 measure proves the secondary academics variant rendered.
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
// The admissions template must be phase-aware: the primaries' distance
// copy ("non-faith primaries") must never appear on a secondary comparison
// (expert sign-off must-fix M3).
await expect(page.getByText(/non-faith primaries/)).toHaveCount(0);
});
test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
// Regression: the first visit stores a basket in localStorage; opening a
// link for a DIFFERENT school set then raced a stale fetch for the stored
// basket against the new SSR data, blanking every section (including the
// trends chart) until a hard refresh.
const [s0, s1] = await twoSecondaryUrns(page);
const [p0, p1] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${s0},${s1}`);
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
timeout: 15_000,
});
await page.goto(`/compare?urns=${p0},${p1}`);
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
timeout: 15_000,
});
// Give any straggling stale response time to land, then confirm the new
// comparison is still on screen.
await page.waitForTimeout(1500);
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible();
await expect(page.getByRole('heading', { name: 'Explore trends' }).first()).toBeVisible();
await expect(page.locator(`a[href*="${p0}"]`).first()).toBeVisible();
});
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await searchByName(page, 'primary'); await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
const hrefs = await schoolLinks(page).evaluateAll((links) => const hrefs = await schoolLinks(page).evaluateAll((links) =>
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '') links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
); );
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))]; const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
expect(urns.length).toBeGreaterThanOrEqual(2); // Compare three schools, not two: a "primary" search can return all-through
// schools that classify as secondary, and the chips only appear for the
// active phase. With three schools across two phases, the auto-selected
// majority phase always holds ≥2, so the chip legend is guaranteed to render.
expect(urns.length).toBeGreaterThanOrEqual(3);
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`); await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
// Both schools' detail links should render in the comparison view
await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 }); // Mobile is measure-first: the At a glance section stacks all active-phase
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible(); // schools inside one flow — no horizontal swiping between school columns.
await expect(
page.getByRole('heading', { name: 'At a glance' }),
).toBeVisible({ timeout: 15_000 });
const body = page.locator('body');
const bodyOverflowsX = await body.evaluate(
(el) => el.scrollWidth > el.clientWidth + 1,
);
expect(bodyOverflowsX).toBe(false);
// The sticky school bar must pin *below* the sticky site header, not at
// top:0 where the header covers it and the selected schools are hidden.
// Assert the sticky offset directly (robust — no scroll timing needed).
const barTop = await page
.locator('[class*="schoolBar"]')
.first()
.evaluate((el) => parseFloat(getComputedStyle(el).top));
const headerHeight = await page
.locator('[class*="header"]')
.first()
.evaluate((el) => el.getBoundingClientRect().height);
expect(barTop).toBeGreaterThanOrEqual(headerHeight - 1);
// The trends chart still renders (inside the Explore trends section)…
const chartCanvas = page.locator('canvas:visible').first();
await expect(chartCanvas).toBeVisible({ timeout: 15_000 });
// …at a real height, not the squashed ~150px Chart.js fallback that
// appears when the container lacks a definite height.
const chartBox = await chartCanvas.boundingBox();
expect(chartBox && chartBox.height).toBeGreaterThan(220);
// …with the mobile chart legend chips and tap-to-focus behaviour intact.
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
const chips = chipGroup.getByRole('button');
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
expect(await chips.count()).toBeGreaterThanOrEqual(2);
// Tapping a chip focuses that school's line; tapping again releases it.
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'true');
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
}); });
test('rankings page loads a populated table', async ({ page }) => { test('rankings page loads a populated table', async ({ page }) => {
+3 -1
View File
@@ -22,7 +22,9 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production ENV NODE_ENV=production
# Build argument for FastAPI URL (used by Next.js rewrites at build time) # Default backend URL for any server-side fetch during `next build`. The
# runtime /api proxy reads FASTAPI_URL per request (see app/api/[...path]),
# so the deployed container's env is what actually routes traffic.
ARG FASTAPI_URL=http://backend:80/api ARG FASTAPI_URL=http://backend:80/api
ENV FASTAPI_URL=${FASTAPI_URL} ENV FASTAPI_URL=${FASTAPI_URL}
@@ -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,140 @@
/**
* Getting a place — phase and school-type correctness (expert sign-off
* must-fixes M1/M3):
* - an all-through school's Year 7 round must never render on the primary
* tab as if it were Reception odds;
* - selective schools get entrance-test framing, and the secondary tab
* never shows the primaries' distance template.
*/
import { render, screen } from '@testing-library/react';
import { CompareAdmissions } from '@/components/compare/CompareAdmissions';
import type { ComparisonData, School, SchoolAdmissions } from '@/lib/types';
function school(urn: number, name: string, extra: Partial<School> = {}): School {
return { urn, school_name: name, ...extra } as School;
}
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
return {
year: 202627,
school_phase: 'Secondary',
places_offered: 173,
total_applications: 433,
first_preference_offer_pct: 83,
oversubscribed: true,
...partial,
} as SchoolAdmissions;
}
function entry(info: School, a: SchoolAdmissions | null): ComparisonData {
return {
school_info: info,
yearly_data: [],
ofsted: null,
census: null,
admissions: a,
admissions_history: a ? [a] : [],
deprivation: null,
};
}
describe('CompareAdmissions', () => {
it("does not show an all-through school's Year 7 round on the primary tab", () => {
// The real M1 scenario: an all-through school (Year 7 round only) beside
// a primary with a Reception round.
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
const primary = school(138690, 'Barclay Primary School');
const data = {
'137306': entry(allThrough, admissions({ school_phase: 'Secondary' })),
'138690': entry(
primary,
admissions({
school_phase: 'Primary',
total_applications: 300,
places_offered: 120,
first_preference_offer_pct: 96,
}),
),
};
render(<CompareAdmissions schools={[allThrough, primary]} data={data} isSecondary={false} />);
// Hessle's Year 7 figures must not appear…
expect(screen.queryByText('433')).toBeNull();
expect(
screen.getByText(/We don't hold Reception admissions data for this school/),
).toBeInTheDocument();
// …while Barclay's Reception round renders normally.
expect(screen.getByText('300')).toBeInTheDocument();
});
it('phase-labels the section empty state when no matching round exists at all', () => {
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={false} />);
expect(
screen.getByText(/No Reception admissions data is available for these schools yet/),
).toBeInTheDocument();
expect(screen.queryByText('433')).toBeNull();
});
it('shows the Year 7 round on the secondary tab', () => {
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={true} />);
expect(screen.getByText('433')).toBeInTheDocument();
expect(screen.getByText('173')).toBeInTheDocument();
});
it('gives selective schools entrance-test framing, never the distance template', () => {
const grammar = school(136276, 'Watford Grammar School for Boys', {
admissions_policy: 'Selective',
religious_denomination: 'Church of England',
});
const data = {
'136276': entry(grammar, admissions({ first_preference_offer_pct: 43.7 })),
};
render(<CompareAdmissions schools={[grammar]} data={data} isSecondary={true} />);
expect(
screen.getByText(/Entry is by entrance test — the school is selective/),
).toBeInTheDocument();
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
});
it('secondary faith school gets faith-aware copy, not the primaries template', () => {
const faithSchool = school(102052, "Bishop Stopford's School", {
admissions_policy: 'Non-selective',
religious_denomination: 'Church of England',
});
const data = {
'102052': entry(faithSchool, admissions({ first_preference_offer_pct: 68 })),
};
render(<CompareAdmissions schools={[faithSchool]} data={data} isSecondary={true} />);
expect(screen.getByText(/faith-based criteria may apply/)).toBeInTheDocument();
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
});
it('keeps the reviewed distance copy for oversubscribed non-faith primaries', () => {
const primary = school(100140, 'Plumcroft Primary School');
const data = {
'100140': entry(
primary,
admissions({ school_phase: 'Primary', first_preference_offer_pct: 73.4 }),
),
};
render(<CompareAdmissions schools={[primary]} data={data} isSecondary={false} />);
expect(screen.getByText(/for most non-faith primaries, distance decides/)).toBeInTheDocument();
});
});
@@ -0,0 +1,170 @@
import { render, screen } from '@testing-library/react';
import { CompareOfsted } from '@/components/compare/CompareOfsted';
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
function school(urn: number, name: string): School {
return { urn, school_name: name } as School;
}
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
return {
framework: null,
inspection_date: '2021-10-07',
inspection_type: null,
overall_effectiveness: null,
quality_of_education: null,
behaviour_attitudes: null,
personal_development: null,
leadership_management: null,
early_years_provision: null,
previous_overall: null,
rc_safeguarding_met: null,
rc_inclusion: null,
rc_curriculum_teaching: null,
rc_achievement: null,
rc_attendance_behaviour: null,
rc_personal_development: null,
rc_leadership_governance: null,
rc_early_years: null,
rc_sixth_form: null,
ofsted_page_url: 'https://reports.ofsted.gov.uk/provider/21/1',
...partial,
};
}
const schools = [school(1, 'Graded School'), school(2, 'Carried School'), school(3, 'Card School')];
const data: Record<string, ComparisonData> = {
'1': {
school_info: schools[0],
yearly_data: [],
ofsted: ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
},
'2': {
school_info: schools[1],
yearly_data: [],
ofsted: ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
},
'3': {
school_info: schools[2],
yearly_data: [],
ofsted: ofsted({
inspection_date: '2025-11-14',
rc_safeguarding_met: true,
report_card: {
rc_achievement: { code: 2, label: 'Strong standard' },
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
},
}),
},
};
describe('CompareOfsted', () => {
it('renders the three regimes without inventing an overall grade for report cards', () => {
render(<CompareOfsted schools={schools} data={data} />);
expect(screen.getByText('Outstanding')).toBeInTheDocument();
// Carried-forward grade is shown but marked as such
expect(screen.getByText('Good')).toBeInTheDocument();
expect(screen.getByText(/carried forward/i)).toBeInTheDocument();
// Report card: label present, no overall-grade badge for that school
expect(screen.getByText('Report card')).toBeInTheDocument();
expect(screen.getByText(/no overall grade/i)).toBeInTheDocument();
});
it('uses one chip-list grammar for both regimes in judgement detail', () => {
render(<CompareOfsted schools={schools} data={data} />);
// report-card area chip
expect(screen.getByText('Attendance & behaviour')).toBeInTheDocument();
expect(screen.getByText('Needs attention')).toBeInTheDocument();
// graded school without published subgrades → honest dataset statement
expect(
screen.getAllByText(/We don't hold area-by-area detail/i).length,
).toBeGreaterThanOrEqual(1);
});
it('shows the mixed-regime comparability note only when regimes differ', () => {
render(<CompareOfsted schools={schools} data={data} />);
expect(screen.getByText(/aren't directly comparable/i)).toBeInTheDocument();
});
it('links every school to its Ofsted page', () => {
render(<CompareOfsted schools={schools} data={data} />);
const links = screen.getAllByRole('link', { name: /Ofsted page/i });
expect(links).toHaveLength(3);
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
});
it('never renders Ofsted sentinel codes (9 = not applicable) as judgement chips', () => {
const sentinelSchool = school(6, 'Sentinel School');
const sentinelData: Record<string, ComparisonData> = {
'6': {
school_info: sentinelSchool,
yearly_data: [],
ofsted: ofsted({
overall_effectiveness: 2,
grade_source: 'graded',
quality_of_education: 1,
early_years_provision: 9,
sixth_form_provision: 2,
}),
},
};
render(<CompareOfsted schools={[sentinelSchool]} data={sentinelData} />);
// Real grades render…
expect(screen.getByText('Quality of education')).toBeInTheDocument();
// …the applicable sixth-form judgement renders (was previously dropped)…
expect(screen.getByText('Sixth form provision')).toBeInTheDocument();
// …and the not-applicable sentinel never appears, neither as area nor code.
expect(screen.queryByText('Early years provision')).toBeNull();
expect(screen.queryByText('9')).toBeNull();
});
it('dates a report card with the report-card inspection date, never the legacy date', () => {
const cardSchool = school(4, 'Dated Card School');
const cardData: Record<string, ComparisonData> = {
'4': {
school_info: cardSchool,
yearly_data: [],
ofsted: ofsted({
inspection_date: '2021-10-07',
rc_inspection_date: '2026-02-03',
rc_safeguarding_met: true,
report_card: { rc_achievement: { code: 1, label: 'Exceptional' } },
}),
},
};
render(<CompareOfsted schools={[cardSchool]} data={cardData} />);
expect(screen.getByText(/3 Feb 2026/)).toBeInTheDocument();
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
expect(screen.queryByText('4+ years ago')).toBeNull();
});
it('shows an em dash when a report card has no rc_inspection_date yet', () => {
const cardSchool = school(5, 'Undated Card School');
const cardData: Record<string, ComparisonData> = {
'5': {
school_info: cardSchool,
yearly_data: [],
ofsted: ofsted({
inspection_date: '2021-10-07',
rc_inspection_date: null,
rc_safeguarding_met: true,
report_card: { rc_achievement: { code: 1, label: 'Exceptional' } },
}),
},
};
render(<CompareOfsted schools={[cardSchool]} data={cardData} />);
expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
});
it('renders a per-measure mobile tag with the short school name', () => {
render(<CompareOfsted schools={schools} data={data} />);
// Each measure repeats the schools, so the short name ("Graded" from
// "Graded School") appears once per measure (4) via the cell tag.
expect(screen.getAllByText('Graded').length).toBe(4);
expect(screen.getAllByText('Card').length).toBe(4);
});
});
@@ -0,0 +1,78 @@
/**
* Regression: an all-secondary comparison must render the secondary sections.
*
* The basket hydrates from the URL a beat after mount, so the auto-phase
* effect must re-run once selectedSchools arrives — with deps of only
* [comparisonData] it fired once against an empty basket, bailed, and the
* page stayed on an empty "primary" tab ("No primary schools in your
* comparison") even though all schools were secondary.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { ComparisonView } from '@/components/ComparisonView';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import type { ComparisonData, School } from '@/lib/types';
const fetchComparison = jest.fn();
jest.mock('@/lib/api', () => ({
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
function secondarySchool(urn: number, name: string): School {
return {
urn,
school_name: name,
local_authority: 'Testshire',
school_type: 'Academy converter',
attainment_8_score: 55,
phase: 'Secondary',
} as School;
}
function data(urn: number, name: string): ComparisonData {
return {
school_info: secondarySchool(urn, name),
yearly_data: [{ year: 202425, attainment_8_score: 55 }] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
const INITIAL_DATA = {
'300': data(300, 'Gamma High'),
'400': data(400, 'Delta Academy'),
};
test('an all-secondary comparison renders the sections, not an empty primary tab', async () => {
render(
<ComparisonProvider>
<ComparisonView
initialData={INITIAL_DATA}
initialNationalAverages={{
year: 202425,
primary: {},
secondary: { attainment_8_score: 46 },
by_year: [],
}}
initialBenchmarks={undefined}
initialUrns={[300, 400]}
metrics={[]}
selectedMetric="attainment_8_score"
/>
</ComparisonProvider>,
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
});
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
// The sticky bar's rail caption reflects the active phase and count.
expect(screen.getByText('2 secondary schools')).toBeInTheDocument();
expect(fetchComparison).not.toHaveBeenCalled();
});
@@ -0,0 +1,82 @@
/**
* Regression: on refresh, the compare page must show the SSR-rendered data.
*
* The basket hydrates from the URL a beat after mount (selectedSchools is
* empty for the first render), so the fetch effect must not blank the
* SSR payload during that window — and must not refetch data the server
* already provided.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { ComparisonView } from '@/components/ComparisonView';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import type { ComparisonData, School } from '@/lib/types';
const fetchComparison = jest.fn();
jest.mock('@/lib/api', () => ({
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
function school(urn: number, name: string): School {
return {
urn,
school_name: name,
local_authority: 'Testshire',
school_type: 'Community school',
rwm_expected_pct: 80,
phase: 'Primary',
} as School;
}
function data(urn: number, name: string): ComparisonData {
return {
school_info: school(urn, name),
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
const INITIAL_DATA = {
'100': data(100, 'Alpha Primary'),
'200': data(200, 'Beta Primary'),
};
beforeEach(() => {
fetchComparison.mockReset();
});
test('renders SSR data on refresh without wiping it or refetching', async () => {
render(
<ComparisonProvider>
<ComparisonView
initialData={INITIAL_DATA}
initialNationalAverages={{
year: 202425,
primary: { rwm_expected_pct: 62 },
secondary: {},
by_year: [],
}}
initialBenchmarks={undefined}
initialUrns={[100, 200]}
metrics={[]}
selectedMetric="rwm_expected_pct"
/>
</ComparisonProvider>,
);
// Both SSR-provided schools appear (data was not blanked during hydration)
await waitFor(() => {
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
});
expect(screen.getAllByText('Beta Primary').length).toBeGreaterThan(0);
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
// …and the client never refetched data the server already rendered.
expect(fetchComparison).not.toHaveBeenCalled();
});
@@ -0,0 +1,107 @@
/**
* Regression: opening a compare link while a DIFFERENT basket is stored must
* not blank the page.
*
* The basket hydrates from localStorage first, which can fire a fetch for the
* OLD school set; the URL-seed effect then replaces the basket with the URL's
* schools (already covered by SSR data, so no new fetch). When the stale
* response for the old set finally lands, it must not clobber the fresh SSR
* data — that left every section (including the trends chart) empty until a
* hard refresh.
*/
import { act, render, screen, waitFor } from '@testing-library/react';
import { ComparisonView } from '@/components/ComparisonView';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import type { ComparisonData, School } from '@/lib/types';
const fetchComparison = jest.fn();
jest.mock('@/lib/api', () => ({
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
function school(urn: number, name: string): School {
return {
urn,
school_name: name,
local_authority: 'Testshire',
school_type: 'Community school',
rwm_expected_pct: 80,
phase: 'Primary',
} as School;
}
function data(urn: number, name: string): ComparisonData {
return {
school_info: school(urn, name),
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
// The visitor's previously stored basket (a different school entirely).
const STORED_SCHOOL = school(900, 'Old Stored School');
// The comparison the URL (and SSR) actually asked for.
const URL_DATA = {
'100': data(100, 'Alpha Primary'),
'200': data(200, 'Beta Primary'),
};
beforeEach(() => {
fetchComparison.mockReset();
localStorage.clear();
});
test('a stale fetch for the previously stored basket does not clobber the URL comparison', async () => {
localStorage.setItem('selectedSchools', JSON.stringify([STORED_SCHOOL]));
const pending: Array<(v: unknown) => void> = [];
fetchComparison.mockImplementation(() => new Promise((resolve) => pending.push(resolve)));
render(
<ComparisonProvider>
<ComparisonView
initialData={URL_DATA}
initialNationalAverages={{
year: 202425,
primary: { rwm_expected_pct: 62 },
secondary: {},
by_year: [],
}}
initialBenchmarks={undefined}
initialUrns={[100, 200]}
metrics={[]}
selectedMetric="rwm_expected_pct"
/>
</ComparisonProvider>,
);
// The URL's schools render from SSR data once the basket is reseeded.
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
});
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
// The transient stored-basket fetch (for school 900) resolves LATE, after
// the basket has moved on to the URL's schools.
await act(async () => {
for (const resolve of pending) {
resolve({
comparison: { '900': data(900, 'Old Stored School') },
national_averages: { year: 202425, primary: {}, secondary: {}, by_year: [] },
benchmarks: undefined,
});
}
});
// The page must still show the URL comparison — not go blank.
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
});
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import { DotStrip } from '@/components/DotStrip';
describe('DotStrip', () => {
it('enumerates anchor and school values in the aria-label', () => {
render(
<DotStrip
label="Reading"
values={[91, 92, 87]}
schoolNames={['Barclay', 'Elmhurst', 'Plumcroft']}
anchor={{ value: 75, label: 'England 75%' }}
/>,
);
const strip = screen.getByRole('img');
expect(strip).toHaveAccessibleName(
'Reading: England 75%, Barclay 91%, Elmhurst 92%, Plumcroft 87%',
);
});
it('renders the anchor tick when provided and not otherwise', () => {
const { rerender } = render(
<DotStrip
label="Reading"
values={[91]}
schoolNames={['Barclay']}
anchor={{ value: 75, label: 'England 75%' }}
/>,
);
expect(screen.getByText('England 75%')).toBeInTheDocument();
rerender(
<DotStrip label="Science" values={[95]} schoolNames={['Barclay']} anchor={null} />,
);
expect(screen.queryByText(/England/)).not.toBeInTheDocument();
});
it('skips schools without a value', () => {
render(
<DotStrip
label="Maths"
values={[91, null]}
schoolNames={['Barclay', 'Elmhurst']}
/>,
);
expect(screen.getByRole('img')).toHaveAccessibleName('Maths: Barclay 91%');
});
});
@@ -0,0 +1,67 @@
/**
* SecondarySchoolRow — sixth-form tag must come from the GIAS
* has_sixth_form flag, not the age_range-contains-"18" heuristic.
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { SecondarySchoolRow } from '@/components/SecondarySchoolRow';
import type { School } from '@/lib/types';
const base = {
urn: 100002,
school_name: 'Beta Sixth Form College',
local_authority: 'Testshire',
school_type: 'Academy',
phase: 'Secondary',
gender: 'Mixed',
attainment_8_score: 50.0,
} as unknown as School;
describe('SecondarySchoolRow sixth-form tag', () => {
it('shows the tag for a 16-19 college with the GIAS flag set', () => {
render(
<SecondarySchoolRow
school={{ ...base, age_range: '16-19', has_sixth_form: true }}
/>,
);
expect(screen.getByText('Sixth form')).toBeInTheDocument();
});
it('hides the tag for an 11-18 school without a registered sixth form', () => {
render(
<SecondarySchoolRow
school={{ ...base, age_range: '11-18', has_sixth_form: false }}
/>,
);
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
});
it('hides the tag when the flag is missing (pipeline not yet re-run)', () => {
render(
<SecondarySchoolRow school={{ ...base, age_range: '11-18' }} />,
);
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
});
});
describe('SecondarySchoolRow proposed-to-close tag', () => {
it('shows the tag when GIAS status is "Open, but proposed to close"', () => {
render(
<SecondarySchoolRow
school={{ ...base, status: 'Open, but proposed to close' }}
/>,
);
expect(screen.getByText(/Proposed to close/)).toBeInTheDocument();
});
it('hides the tag for a plain open school', () => {
render(<SecondarySchoolRow school={{ ...base, status: 'Open' }} />);
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
});
it('hides the tag when status is missing', () => {
render(<SecondarySchoolRow school={base} />);
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
});
});
@@ -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();
});
});
@@ -0,0 +1,93 @@
/**
* buildCompareChart: every selected school must produce a rendered series
* (regression guard for the production bug where a third school's line
* vanished), the x-axis must include cancelled/unpublished years as real
* gaps (never compressing time), and the England overlay renders dashed
* with no gap-bridging.
*/
import { buildCompareChart, fillAcademicYears } from '@/lib/compareChartData';
import type { ComparisonData } from '@/lib/types';
function school(urn: number, years: Array<[number, number | null]>): ComparisonData {
return {
school_info: { urn, school_name: `School ${urn}` } as ComparisonData['school_info'],
yearly_data: years.map(([year, v]) => ({ year, rwm_expected_pct: v })) as ComparisonData['yearly_data'],
};
}
const THREE_SCHOOLS = {
'1': school(1, [[201819, 87], [202223, 87], [202425, 87]]),
'2': school(2, [[201819, 88], [202223, 88], [202425, 92]]),
'3': school(3, [[201819, 69], [202223, 62], [202425, 79]]),
};
const SCHOOL_LIST = [1, 2, 3].map((urn) => ({ urn, school_name: `School ${urn}` }));
describe('fillAcademicYears', () => {
it('fills every academic year between min and max', () => {
expect(fillAcademicYears([201819, 202223])).toEqual([
201819, 201920, 202021, 202122, 202223,
]);
});
});
describe('buildCompareChart', () => {
it('renders one series per selected school — none silently dropped', () => {
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
expect(chart.schoolDatasets).toHaveLength(3);
for (const ds of chart.schoolDatasets) {
expect(ds.data.some((v) => v != null)).toBe(true);
}
});
it('handles float years from the API (202425.0 style)', () => {
const floaty = {
'1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]),
};
const chart = buildCompareChart(floaty, [SCHOOL_LIST[0]], 'rwm_expected_pct');
expect(chart.schoolDatasets[0].data.filter((v) => v != null)).toHaveLength(2);
});
it('includes cancelled/unpublished years as null gaps, not compressed time', () => {
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
expect(chart.years).toContain(201920);
expect(chart.years).toContain(202122);
const idx = chart.years.indexOf(202021);
expect(chart.schoolDatasets[0].data[idx]).toBeNull();
});
it('adds a dashed England overlay when national data is supplied', () => {
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
201819: 64.9,
202122: 58.7,
202223: 59.5,
202425: 62.1,
});
expect(chart.englandDataset).not.toBeNull();
const eng = chart.englandDataset!;
expect(eng.label).toBe('England average');
expect(eng.borderDash).toEqual([5, 4]);
expect(eng.spanGaps).toBe(false);
// England has a value for 2021/22 even though schools do not
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', () => {
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
202122: 58.7,
});
expect(withNational.showUnpublished202122Note).toBe(true);
const withoutNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
expect(withoutNational.showUnpublished202122Note).toBe(false);
});
});
@@ -0,0 +1,315 @@
/**
* compareLogic encodes the expert-reviewed comprehension rules for the
* compare screen: report-card summarisation (safeguarding never counted),
* three-regime Ofsted display, one consistent admissions chip metric,
* CI-based progress banding, verdict chips and dot-strip geometry.
*/
import {
OFSTED_LEGACY_GRADES,
admissionsForPhase,
ofstedDisplay,
progressBand,
rcAreaLabel,
stripPositions,
summariseAdmissions,
summariseReportCard,
verdict,
} from '@/lib/compareLogic';
import type { OfstedInspection, SchoolAdmissions } from '@/lib/types';
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
return {
framework: null,
inspection_date: null,
inspection_type: null,
overall_effectiveness: null,
quality_of_education: null,
behaviour_attitudes: null,
personal_development: null,
leadership_management: null,
early_years_provision: null,
previous_overall: null,
rc_safeguarding_met: null,
rc_inclusion: null,
rc_curriculum_teaching: null,
rc_achievement: null,
rc_attendance_behaviour: null,
rc_personal_development: null,
rc_leadership_governance: null,
rc_early_years: null,
rc_sixth_form: null,
...partial,
};
}
const REPORT_CARD = {
rc_achievement: { code: 2, label: 'Strong standard' },
rc_curriculum_teaching: { code: 2, label: 'Strong standard' },
rc_personal_development: { code: 2, label: 'Strong standard' },
rc_leadership_governance: { code: 2, label: 'Strong standard' },
rc_inclusion: { code: 3, label: 'Expected standard' },
rc_early_years: { code: 3, label: 'Expected standard' },
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
};
describe('summariseReportCard', () => {
it('counts graded areas best-first and NAMES problem areas', () => {
const s = summariseReportCard(
ofsted({ report_card: REPORT_CARD, rc_safeguarding_met: true }),
);
expect(s.counts).toEqual([
{ label: 'Strong standard', count: 4 },
{ label: 'Expected standard', count: 2 },
]);
expect(s.problems).toEqual([
{ areaLabel: 'Attendance & behaviour', label: 'Needs attention' },
]);
expect(s.safeguarding).toBe('met');
expect(s.allClear).toBe(false);
});
it('never counts safeguarding as a graded area', () => {
const s = summariseReportCard(
ofsted({
report_card: { rc_achievement: { code: 3, label: 'Expected standard' } },
rc_safeguarding_met: true,
}),
);
const total = s.counts.reduce((n, c) => n + c.count, 0);
expect(total).toBe(1);
});
it('is allClear when everything is Expected standard or better and safeguarding met', () => {
const s = summariseReportCard(
ofsted({
report_card: {
rc_achievement: { code: 3, label: 'Expected standard' },
rc_inclusion: { code: 1, label: 'Exceptional' },
},
rc_safeguarding_met: true,
}),
);
expect(s.allClear).toBe(true);
expect(s.counts[0]).toEqual({ label: 'Exceptional', count: 1 });
});
it('passes labels through from the API — never invents wording', () => {
const s = summariseReportCard(
ofsted({ report_card: { rc_inclusion: { code: 4, label: 'Needs attention' } } }),
);
expect(JSON.stringify(s)).not.toContain('Attention needed');
});
});
describe('ofstedDisplay', () => {
it('prefers the report card over any legacy grade', () => {
const d = ofstedDisplay(
ofsted({ overall_effectiveness: 2, report_card: REPORT_CARD }),
);
expect(d.kind).toBe('report_card');
});
it('distinguishes graded from carried-forward grades', () => {
const graded = ofstedDisplay(
ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
);
expect(graded).toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', carriedForward: false });
const carried = ofstedDisplay(
ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
);
expect(carried).toMatchObject({ kind: 'carried_forward', gradeLabel: 'Good', carriedForward: true });
});
it('handles missing data', () => {
expect(ofstedDisplay(null).kind).toBe('none');
expect(ofstedDisplay(ofsted({})).kind).toBe('none');
});
it('identifies transitional inspections without overall grades', () => {
const transitional = ofstedDisplay(
ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }),
);
expect(transitional.kind).toBe('transitional');
});
it('uses the four legacy grade words', () => {
expect(OFSTED_LEGACY_GRADES).toEqual({
1: 'Outstanding',
2: 'Good',
3: 'Requires improvement',
4: 'Inadequate',
});
});
});
describe('rcAreaLabel', () => {
it('maps rc keys to the mockups area labels', () => {
expect(rcAreaLabel('rc_attendance_behaviour')).toBe('Attendance & behaviour');
expect(rcAreaLabel('rc_curriculum_teaching')).toBe('Curriculum & teaching');
expect(rcAreaLabel('rc_leadership_governance')).toBe('Leadership & governance');
});
});
describe('summariseAdmissions', () => {
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
return {
year: 202627,
places_offered: null,
total_applications: null,
first_preference_offer_pct: null,
oversubscribed: null,
...partial,
};
}
it('97% → good chip with the mockup wording', () => {
const s = summariseAdmissions(
admissions({ first_preference_offer_pct: 96.98, total_applications: 457, places_offered: 180 }),
);
expect(s.chip).toEqual({ tone: 'good', text: '97% of first choices offered' });
expect(s.interest).toBe('Named on 457 forms · 180 places');
});
it('73% → warn chip "Over 1 in 4 first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 73.4 }));
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"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
});
it('no data → null chip and interest', () => {
const s = summariseAdmissions(null);
expect(s.chip).toBeNull();
expect(s.interest).toBeNull();
});
});
describe('admissionsForPhase', () => {
const row = (year: number, school_phase: string | null): SchoolAdmissions =>
({ year, school_phase, places_offered: 100, total_applications: 200, first_preference_offer_pct: 80 }) as SchoolAdmissions;
it('returns the latest round matching the active phase', () => {
const data = {
admissions: row(202627, 'Secondary'),
admissions_history: [row(202526, 'Secondary'), row(202526, 'Primary'), row(202425, 'Primary')],
};
expect(admissionsForPhase(data, true)?.year).toBe(202627);
expect(admissionsForPhase(data, false)?.year).toBe(202526);
expect(admissionsForPhase(data, false)?.school_phase).toBe('Primary');
});
it("never substitutes the other phase's round (all-through with Year 7 data only)", () => {
const data = {
admissions: row(202627, 'Secondary'),
admissions_history: [row(202526, 'Secondary')],
};
expect(admissionsForPhase(data, false)).toBeNull();
expect(admissionsForPhase(data, true)?.year).toBe(202627);
});
it('uses untagged legacy rows only when no row carries a phase', () => {
const untagged = { admissions: row(202627, null), admissions_history: [row(202526, null)] };
expect(admissionsForPhase(untagged, false)?.year).toBe(202627);
expect(admissionsForPhase(untagged, true)?.year).toBe(202627);
const mixed = { admissions: row(202627, 'Secondary'), admissions_history: [row(202526, null)] };
expect(admissionsForPhase(mixed, false)).toBeNull();
});
it('handles missing data', () => {
expect(admissionsForPhase(null, false)).toBeNull();
expect(admissionsForPhase({ admissions: null, admissions_history: [] }, true)).toBeNull();
});
});
describe('progressBand', () => {
it('CI entirely above zero → above', () => {
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
});
it('CI entirely below zero → below', () => {
expect(progressBand(-1.2, -2.0, -0.4)).toBe('below');
});
it('CI straddling zero → average', () => {
expect(progressBand(0.3, -0.5, 1.1)).toBe('average');
});
it('missing CI → null (no naive thresholding)', () => {
expect(progressBand(1.2, null, null)).toBeNull();
expect(progressBand(null, null, null)).toBeNull();
});
});
describe('verdict', () => {
it('above / close / below with a 2pp tolerance', () => {
expect(verdict(87, 62)).toBe('above');
expect(verdict(61, 62)).toBe('close');
expect(verdict(40, 62)).toBe('below');
});
});
describe('stripPositions', () => {
it('maps a custom domain', () => {
const pts = stripPositions([106], 100, 120);
expect(pts[0].pos).toBe(30);
});
it('flips a colliding label above', () => {
const pts = stripPositions([91, 92], 0, 100);
const sorted = [...pts].sort((a, b) => a.value - b.value);
expect(sorted[0].labelAbove).toBe(false);
expect(sorted[1].labelAbove).toBe(true);
});
it('skips nulls and keeps school indices', () => {
const pts = stripPositions([50, null, 70], 0, 100);
expect(pts).toHaveLength(2);
expect(pts.map((p) => p.schoolIndex)).toEqual([0, 2]);
});
it('clamps out-of-domain values', () => {
const pts = stripPositions([95], 100, 120);
expect(pts[0].pos).toBe(0);
});
});
describe('latestValues', () => {
const data = {
'1': {
yearly_data: [
{ year: 202324, rwm_expected_pct: 75 },
{ year: 202425, rwm_expected_pct: 87 },
],
},
'2': {
yearly_data: [
{ year: 202324, rwm_expected_pct: 82 },
{ year: 202425, rwm_expected_pct: null },
],
},
};
it('takes the latest non-null value per school in urn order', async () => {
const { latestValues } = await import('@/lib/compareLogic');
expect(latestValues(data, [1, 2], 'rwm_expected_pct')).toEqual([87, 82]);
});
it('returns null for unknown schools and metrics', async () => {
const { latestValues } = await import('@/lib/compareLogic');
expect(latestValues(data, [3], 'rwm_expected_pct')).toEqual([null]);
expect(latestValues(data, [1], 'nope')).toEqual([null]);
});
});
+164 -3
View File
@@ -9,6 +9,10 @@ import {
isValidPostcode, isValidPostcode,
debounce, debounce,
buildOfstedListBadge, buildOfstedListBadge,
ofstedLegacyAreas,
metricKind,
shortName,
computeYBounds,
} from '@/lib/utils'; } from '@/lib/utils';
describe('formatPercentage', () => { describe('formatPercentage', () => {
@@ -108,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' });
@@ -127,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');
}); });
@@ -159,3 +206,117 @@ describe('buildOfstedListBadge', () => {
expect(badge.cssClass).toBe('ofstedPending'); expect(badge.cssClass).toBe('ofstedPending');
}); });
}); });
describe('metricKind', () => {
it('classifies metrics by key', () => {
expect(metricKind('rwm_expected_pct')).toBe('percentage');
expect(metricKind('absence_rate')).toBe('percentage');
expect(metricKind('reading_progress')).toBe('progress');
expect(metricKind('progress_8_score')).toBe('progress');
expect(metricKind('attainment_8_score')).toBe('score');
expect(metricKind('reading_avg_score')).toBe('score');
});
});
describe('computeYBounds', () => {
it('tightens clustered percentages instead of framing 0-100', () => {
const b = computeYBounds([86, 86, 86, 80, 96], 'percentage');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.max).toBeLessThanOrEqual(100);
expect(b.min).toBeGreaterThan(50);
expect(b.max! - b.min!).toBeGreaterThanOrEqual(10);
});
it('never widens percentages beyond 0-100 for non-negative data', () => {
const b = computeYBounds([2, 5, 98], 'percentage');
expect(b.min).toBe(0);
expect(b.max).toBe(100);
});
it('does not clamp to zero when pct-named trend data is negative', () => {
const b = computeYBounds([-12, -3, 4], 'percentage');
expect(b.min).toBeLessThan(-12);
});
it('keeps progress bounds symmetric around zero', () => {
const b = computeYBounds([-1.2, 0.4, 2.1], 'progress');
expect(b.min).toBe(-b.max!);
expect(b.min).toBeLessThanOrEqual(-1.2);
expect(b.max).toBeGreaterThanOrEqual(2.1);
});
it('fits score metrics without a fixed frame', () => {
const b = computeYBounds([42.3, 48.9, 51.2], 'score');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.min).toBeLessThanOrEqual(42.3);
expect(b.max).toBeGreaterThanOrEqual(51.2);
});
it('returns empty bounds when there is no numeric data', () => {
expect(computeYBounds([null, undefined, NaN], 'percentage')).toEqual({});
expect(computeYBounds([], 'progress')).toEqual({});
});
});
describe('isProposedToClose', () => {
const { isProposedToClose } = require('@/lib/utils');
it('is true only for the exact GIAS proposed-to-close status', () => {
expect(isProposedToClose({ status: 'Open, but proposed to close' })).toBe(true);
expect(isProposedToClose({ status: 'Open' })).toBe(false);
expect(isProposedToClose({ status: null })).toBe(false);
expect(isProposedToClose({})).toBe(false);
});
});
describe('isSpecialSchool', () => {
const { isSpecialSchool } = require('@/lib/utils');
it('matches every DfE special-school establishment type', () => {
for (const t of [
'Community special school',
'Foundation special school',
'Academy special converter',
'Academy special sponsor led',
'Non-maintained special school',
'Free schools special',
'Other independent special school',
]) {
expect(isSpecialSchool({ school_type: t })).toBe(true);
}
});
it('matches pupil referral units and alternative provision', () => {
expect(isSpecialSchool({ school_type: 'Pupil referral unit' })).toBe(true);
expect(isSpecialSchool({ school_type: 'Academy alternative provision converter' })).toBe(true);
});
it('does not match mainstream types (no false positives)', () => {
for (const t of [
'Academy converter',
'Community school',
'Voluntary aided school',
'Free schools',
'University technical college',
'Other independent school',
null,
undefined,
]) {
expect(isSpecialSchool({ school_type: t })).toBe(false);
}
});
});
describe('shortName', () => {
it('drops the trailing establishment-type words', () => {
expect(shortName('Barclay Primary School')).toBe('Barclay');
expect(shortName('Elmhurst Primary School')).toBe('Elmhurst');
expect(shortName("St Mary's Catholic Primary School")).toBe("St Mary's");
expect(shortName('Riverside Community Junior School')).toBe('Riverside');
});
it('keeps a name that carries no type suffix, capping very long ones', () => {
expect(shortName('Beaver Road')).toBe('Beaver Road');
expect(shortName('A'.repeat(30), 10)).toBe('AAAAAAAAA…');
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* Runtime proxy for /api/* → the FastAPI backend.
*
* This replaces the old next.config.js `rewrites()` proxy, whose destination
* was baked into the build (routes-manifest.json) from FASTAPI_URL at build
* time. Because one frontend image is promoted staging→prod, a baked hostname
* forced every environment to name the backend identically; a mismatch (e.g.
* a `backend_stg` service) produced `getaddrinfo ENOTFOUND backend`.
*
* A route handler reads process.env.FASTAPI_URL on each request, so the same
* image adapts to whatever the backend is called in each environment.
*/
import { type NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
// FASTAPI_URL already includes the `/api` suffix (e.g. http://backend:80/api).
function backendBase(): string {
return process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
}
// Hop-by-hop / length headers must not be copied across a proxy — undici has
// already decoded the body, so a stale content-encoding/length corrupts it.
const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`;
const headers = new Headers(req.headers);
headers.delete('host');
headers.delete('connection');
const init: RequestInit & { duplex?: 'half' } = {
method: req.method,
headers,
redirect: 'manual',
cache: 'no-store',
};
if (METHODS_WITH_BODY.has(req.method)) {
init.body = req.body;
init.duplex = 'half';
}
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch (err) {
// e.g. DNS failure or connection refused — surface a clean 502 instead of
// an opaque proxy crash so callers can degrade gracefully.
return NextResponse.json({ detail: 'Upstream request failed' }, { status: 502 });
}
const responseHeaders = new Headers(upstream.headers);
for (const h of STRIPPED_RESPONSE_HEADERS) responseHeaders.delete(h);
return new NextResponse(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
}
export {
handler as GET,
handler as HEAD,
handler as POST,
handler as PUT,
handler as PATCH,
handler as DELETE,
handler as OPTIONS,
};
+16 -16
View File
@@ -16,8 +16,10 @@ interface ComparePageProps {
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Compare Schools', title: 'Compare Schools',
description: 'Compare KS2 performance across multiple primary schools in England', description:
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance', 'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
keywords:
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
}; };
// Dynamic via searchParams; remove force-dynamic so internal data fetches // Dynamic via searchParams; remove force-dynamic so internal data fetches
@@ -30,26 +32,24 @@ export default async function ComparePage({ searchParams }: ComparePageProps) {
const selectedMetric = metricParam || 'rwm_expected_pct'; const selectedMetric = metricParam || 'rwm_expected_pct';
try { try {
// Fetch comparison data if URNs provided // Fetch comparison + metrics in parallel — they are independent.
let comparisonData = null; const [comparisonResponse, metricsResponse] = await Promise.all([
if (urns.length > 0) { urns.length > 0
try { ? fetchComparison(urnsParam!).catch((error) => {
const response = await fetchComparison(urnsParam!);
comparisonData = response.comparison;
} catch (error) {
console.error('Failed to fetch comparison:', error); console.error('Failed to fetch comparison:', error);
} return null;
} })
: Promise.resolve(null),
fetchMetrics(),
]);
// Fetch available metrics
const metricsResponse = await fetchMetrics();
// Metrics is already an array
const metricsArray = metricsResponse?.metrics || []; const metricsArray = metricsResponse?.metrics || [];
return ( return (
<ComparisonView <ComparisonView
initialData={comparisonData} initialData={comparisonResponse?.comparison ?? null}
initialNationalAverages={comparisonResponse?.national_averages}
initialBenchmarks={comparisonResponse?.benchmarks}
initialUrns={urns} initialUrns={urns}
metrics={metricsArray} metrics={metricsArray}
selectedMetric={selectedMetric} selectedMetric={selectedMetric}
+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;
+1 -3
View File
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound(); notFound();
} }
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data; const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL // Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -189,7 +189,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
yearlyData={yearly_data} yearlyData={yearly_data}
absenceData={absence_data} absenceData={absence_data}
ofsted={ofsted ?? null} ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null} census={census ?? null}
admissions={admissions ?? null} admissions={admissions ?? null}
senDetail={sen_detail ?? null} senDetail={sen_detail ?? null}
@@ -203,7 +202,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
yearlyData={yearly_data} yearlyData={yearly_data}
absenceData={absence_data} absenceData={absence_data}
ofsted={ofsted ?? null} ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null} census={census ?? null}
admissions={admissions ?? null} admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []} admissionsHistory={admissions_history ?? []}
+32
View File
@@ -0,0 +1,32 @@
/**
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
*
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
* baking the backend host into the build, so one image works in every
* environment. robots.ts points crawlers here.
*/
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
function backendOrigin(): string {
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
return base.replace(/\/api$/, '');
}
export async function GET() {
let upstream: Response;
try {
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
} catch {
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
}
const body = await upstream.text();
return new NextResponse(body, {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
});
}
+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,
}, },
], ],
@@ -0,0 +1,80 @@
/* Chart wrapper: chips (mobile) above, then the canvas, then the gap note.
The canvas has its OWN definite height (Chart.js needs one for
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 {
display: flex;
flex-direction: column;
}
.canvasBox {
position: relative;
height: 380px;
}
@media (max-width: 640px) {
.canvasBox {
height: 280px;
}
}
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
Chart.js's built-in legend (with per-school point shapes). */
.chips {
display: none;
}
@media (max-width: 640px) {
.chips {
/* Two chips per row so long school names don't crowd into a single
line; each chip fills its column and truncates with an ellipsis. */
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding-bottom: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 40px;
min-width: 0;
padding: 4px 10px;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 999px;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.chip[aria-pressed="true"] {
background: rgba(0, 0, 0, .06);
border-color: rgba(0, 0, 0, .35);
}
.chipDot {
flex: 0 0 auto;
width: 10px;
height: 10px;
border-radius: 50%;
}
.chipName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* min-width:0 lets the name shrink inside the grid cell so the
ellipsis kicks in instead of overflowing. */
min-width: 0;
}
}
.chartNote {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0.5rem 0 0;
}
+160 -51
View File
@@ -1,60 +1,117 @@
/** /**
* ComparisonChart Component * ComparisonChart Component
* Multi-school comparison chart using Chart.js * Multi-school comparison chart using Chart.js.
*
* Desktop: built-in legend (point-style markers double as per-school shapes).
* Mobile (≤640px): the in-chart legend and axis titles are dropped in favour
* of a chip row above the canvas; tapping a chip highlights that school's
* line and dims the rest. The y-axis auto-fits the data on all viewports so
* clustered schools stay distinguishable.
*/ */
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js'; import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import { buildCompareChart } from '@/lib/compareChartData';
import type { ComparisonData } from '@/lib/types'; import type { ComparisonData } from '@/lib/types';
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils'; import {
CHART_COLORS,
CHART_TEXT_COLORS,
computeYBounds,
formatAcademicYear,
metricKind,
rgbToRgba,
} from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics';
import styles from './ComparisonChart.module.css';
interface ComparisonChartProps { interface ComparisonChartProps {
comparisonData: Record<string, ComparisonData>; comparisonData: Record<string, ComparisonData>;
/** Ordered as displayed in the school cards, so colours match by index. */
schools: Array<{ urn: number; school_name: string }>;
metric: string; metric: string;
metricLabel: string; metricLabel: string;
/** Official England figure per academic year for this metric — renders a
* dashed grey reference line when provided. */
nationalByYear?: Record<number, number | null | undefined>;
/** KS4 metrics get a different (honest) gap caption than KS2. */
isSecondary?: boolean;
} }
export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) { // One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// Get all schools and their data // converging lines stay tellable apart without relying on hue alone.
const schools = Object.entries(comparisonData); const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
// A focused school that leaves the basket must not linger.
const urnKey = schools.map((s) => s.urn).join(',');
useEffect(() => {
setFocusedUrn(null);
}, [urnKey]);
if (schools.length === 0) { if (schools.length === 0) {
return <div>No data available</div>; return <div>No data available</div>;
} }
// Get years from first school (assuming all schools have same years) // Pure, tested series construction: union of years with cancelled /
const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b); // unpublished years kept as real gaps, plus the England overlay.
const built = buildCompareChart(comparisonData, schools, metric, nationalByYear);
const { years } = built;
// Create datasets for each school const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
const datasets = schools.map(([urn, data], index) => { const school = schools[series.schoolIndex];
const schoolInfo = data.school_info; const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
const color = CHART_COLORS[index % CHART_COLORS.length]; const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
return { return {
label: schoolInfo.school_name, label: series.label,
data: years.map((year) => { data: series.data,
const yearData = data.yearly_data.find((d) => d.year === year); borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
if (!yearData) return null; backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
return yearData[metric as keyof typeof yearData] as number | null; borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
}), pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length],
borderColor: color, pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'), pointHoverRadius: isMobile ? 5 : 6,
tension: 0.3, tension: 0.3,
spanGaps: true, // Never bridge missing years — gaps are information (COVID
// cancellations, unpublished 2021/22, schools that opened later).
spanGaps: false,
}; };
}); });
if (built.englandDataset) {
datasets.push({
label: built.englandDataset.label,
data: built.englandDataset.data,
borderColor: 'rgba(109, 104, 95, 0.9)',
backgroundColor: 'transparent',
borderWidth: 1.5,
borderDash: built.englandDataset.borderDash,
pointStyle: 'line',
pointRadius: 0,
pointHoverRadius: 4,
tension: 0,
spanGaps: false,
});
}
const chartData = { const chartData = {
labels: years.map(formatAcademicYear), labels: years.map(formatAcademicYear),
datasets, datasets,
}; };
// Determine if metric is a progress score or percentage const kind = metricKind(metric);
const isProgressScore = metric.includes('progress'); const yBounds = computeYBounds(
const isPercentage = metric.includes('pct') || metric.includes('rate'); datasets.flatMap((ds) => ds.data as Array<number | null>),
kind,
);
const options: ChartOptions<'line'> = { const options: ChartOptions<'line'> = {
responsive: true, responsive: true,
@@ -65,6 +122,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
}, },
plugins: { plugins: {
legend: { legend: {
display: !isMobile,
position: 'top' as const, position: 'top' as const,
labels: { labels: {
usePointStyle: true, usePointStyle: true,
@@ -74,26 +132,22 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
}, },
}, },
}, },
// No in-chart title: the section heading and metric selector above the
// chart already state the metric.
title: { title: {
display: true, display: false,
text: `${metricLabel} - Comparison`,
font: {
size: 16,
weight: 'bold',
},
padding: {
bottom: 20,
},
}, },
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)', backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12, padding: isMobile ? 10 : 12,
titleFont: { titleFont: {
size: 14, size: isMobile ? 12 : 14,
}, },
bodyFont: { bodyFont: {
size: 13, size: isMobile ? 11 : 13,
}, },
usePointStyle: true,
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
callbacks: { callbacks: {
label: function (context) { label: function (context) {
let label = context.dataset.label || ''; let label = context.dataset.label || '';
@@ -101,13 +155,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
label += ': '; label += ': ';
} }
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
if (isProgressScore) { label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : '');
label += context.parsed.y.toFixed(1);
} else if (isPercentage) {
label += context.parsed.y.toFixed(1) + '%';
} else {
label += context.parsed.y.toFixed(1);
}
} else { } else {
label += 'N/A'; label += 'N/A';
} }
@@ -121,17 +169,18 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
type: 'linear' as const, type: 'linear' as const,
display: true, display: true,
title: { title: {
display: true, display: !isMobile,
text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value', text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
}, },
}, },
...(isPercentage && { ...yBounds,
min: 0, ticks: {
max: 100, font: { size: isMobile ? 10 : 12 },
}), ...(isMobile && { maxTicksLimit: 5 }),
},
grid: { grid: {
color: 'rgba(0, 0, 0, 0.05)', color: 'rgba(0, 0, 0, 0.05)',
}, },
@@ -141,16 +190,76 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
display: false, display: false,
}, },
title: { title: {
display: true, display: !isMobile,
text: 'Year', text: 'Year',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
}, },
}, },
ticks: {
font: { size: isMobile ? 10 : 12 },
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
},
}, },
}, },
}; };
return <Line data={chartData} options={options} />; const toggleFocus = (urn: number) => {
const next = focusedUrn === urn ? null : urn;
setFocusedUrn(next);
if (next !== null) track('compare_focus_school', { urn: next });
};
return (
<div className={styles.wrapper}>
{/* Mobile legend + focus control; a single series needs no legend. */}
{schools.length > 1 && (
<div className={styles.chips} role="group" aria-label="Highlight a school on the chart">
{schools.map((school, index) => (
<button
key={school.urn}
type="button"
className={styles.chip}
aria-pressed={focusedUrn === school.urn}
onClick={() => toggleFocus(school.urn)}
>
<span
className={styles.chipDot}
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<span
className={styles.chipName}
style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}
>
{school.school_name}
</span>
</button>
))}
</div>
)}
<div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div>
{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}>
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.
</p>
)
)}
</div>
);
} }
+147 -361
View File
@@ -28,8 +28,15 @@
color: var(--text-secondary, #5c564d); color: var(--text-secondary, #5c564d);
margin: 0; margin: 0;
line-height: 1.6; line-height: 1.6;
max-width: 60ch;
} }
.headerActions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
/* Phase Tabs */ /* Phase Tabs */
.phaseTabs { .phaseTabs {
@@ -72,405 +79,184 @@
background: var(--accent-coral-darker, #9c3f26); background: var(--accent-coral-darker, #9c3f26);
} }
/* Metric Selector */ /* Sticky school bar — column identity while scrolling; horizontal scroll on
.metricSelector { narrow screens. Offset by the sticky site header's height (Navigation is
background: var(--bg-card, white); position: sticky, top: 0) so this bar pins just below it instead of
border: 1px solid var(--border-color, #e5dfd5); sliding underneath and being hidden. Header ≈ 65px desktop / 57px mobile. */
border-radius: 12px; .schoolBar {
padding: 1.5rem; position: sticky;
margin-bottom: 2rem; top: 65px;
z-index: 10;
background: var(--bg-primary, #faf7f2);
display: flex; display: flex;
align-items: center; gap: 0.75rem;
flex-wrap: wrap;
gap: 1rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.metricLabel {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
white-space: nowrap;
}
.metricSelect {
flex: 1;
max-width: 400px;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 8px;
background: var(--bg-card, white);
color: var(--text-primary, #1a1612);
cursor: pointer;
transition: all 0.2s ease;
}
.metricSelect:hover {
border-color: var(--accent-coral, #e07256);
}
.metricSelect:focus {
outline: none;
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px var(--accent-coral-bg);
}
.metricSelect optgroup {
font-weight: 700;
color: var(--text-primary, #1a1612);
background: var(--bg-secondary, #f3ede4);
padding: 0.5rem 0;
}
.metricSelect option {
font-weight: 400;
color: var(--text-secondary, #5c564d);
padding: 0.375rem 1rem;
}
/* Schools Section */
.schoolsSection {
margin-bottom: 2rem;
}
.schoolsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.schoolCard {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-left: 3px solid var(--accent-teal, #2d7d7d);
border-radius: 12px;
padding: 1.5rem;
position: relative;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
transition: all 0.3s ease;
display: flex;
flex-direction: column;
}
.schoolCard:hover {
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
transform: translateY(-2px);
}
.removeButton {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-coral, #e07256);
color: white;
border: none;
border-radius: 50%;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
}
.removeButton:hover {
background: var(--accent-coral-dark, #c45a3f);
transform: scale(1.1);
}
.schoolName {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.75rem;
padding-right: 2rem;
line-height: 1.3;
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.schoolName a {
color: var(--text-primary, #1a1612);
text-decoration: none;
transition: color 0.2s ease;
}
.schoolName a:hover {
color: var(--accent-coral-dark, #b04a2e);
}
.schoolMeta {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
flex: 1;
}
.metaItem {
font-size: 0.875rem;
color: var(--text-secondary, #5c564d);
display: flex;
align-items: center;
gap: 0.25rem;
}
.latestValue {
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e5dfd5);
text-align: center;
background: var(--bg-secondary, #f3ede4);
margin-left: -1.5rem;
margin-right: -1.5rem;
margin-bottom: -1.5rem;
padding: 1.25rem 1.5rem;
border-radius: 0 0 12px 9px;
}
.latestLabel {
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.latestNumber {
font-size: 1.75rem;
font-weight: 700;
color: var(--accent-teal, #2d7d7d);
}
/* Chart Section */
.chartSection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.sectionTitle {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--border-color, #e5dfd5);
font-family: var(--font-playfair), 'Playfair Display', serif;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sectionTitle::before {
content: '';
display: inline-block;
width: 4px;
height: 1em;
background: var(--accent-coral, #e07256);
border-radius: 2px;
}
.chartContainer {
width: 100%;
height: 400px;
position: relative;
}
.loadingMessage {
text-align: center;
padding: 3rem;
color: var(--text-secondary, #5c564d);
font-size: 1rem;
}
/* Table Section */
.tableSection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.tableWrapper {
overflow-x: auto; overflow-x: auto;
max-width: 100%; padding: 0.75rem 0;
margin-top: 1rem; border-bottom: 1px solid var(--border-light, #e5dfd5);
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* Right-edge fade so phone users see the comparison table scrolls. .schoolChip {
Otherwise the wider-than-viewport table silently clips. */ flex: 1 1 0;
@media (max-width: 640px) { min-width: 180px;
.tableWrapper { background: var(--bg-card, white);
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); border: 1px solid var(--border-light, #e5dfd5);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); border-top: 3px solid var(--accent-coral, #e07256);
} border-radius: 8px;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
padding: 0.55rem 0.75rem;
display: flex;
gap: 0.55rem;
align-items: center;
} }
.comparisonTable { .chipDot {
width: 100%; width: 11px;
border-collapse: separate; height: 11px;
border-spacing: 0; border-radius: 50%;
font-size: 0.9375rem; flex: none;
} }
.comparisonTable thead { .chipText {
background: var(--bg-secondary, #f3ede4); min-width: 0;
} }
.comparisonTable th { .chipName {
padding: 1rem; display: block;
text-align: left;
font-weight: 600; font-weight: 600;
font-size: 0.92rem;
line-height: 1.25;
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
border-bottom: 2px solid var(--border-color, #e5dfd5); text-decoration: none;
background: var(--bg-secondary, #f3ede4); }
.chipName:hover {
color: var(--accent-coral-dark, #b04a2e);
}
/* Full name on desktop, short name on the compact mobile pills. */
.chipNameShort {
display: none;
}
.chipMeta {
display: block;
font-size: 0.78rem;
color: var(--text-muted, #6d685f);
white-space: nowrap; white-space: nowrap;
text-transform: uppercase; overflow: hidden;
font-size: 0.75rem; text-overflow: ellipsis;
letter-spacing: 0.05em;
} }
.comparisonTable td { /* Caption filling the label rail on desktop ("Comparing / 3 primary
padding: 1rem; schools"). Hidden on mobile, where the bar is a row of compact pills. */
border-bottom: 1px solid var(--border-color, #e5dfd5); .barCaption {
color: var(--text-secondary, #5c564d); display: none;
text-align: left;
background: var(--bg-card, white);
} }
/* Sticky first column (Year) so labels remain visible while scrolling */ /* Desktop (matches the sections' 761px breakpoint): the bar adopts the same
.comparisonTable th:first-child, grid template as compareSections' .grid — a 200px row-label rail plus one
.comparisonTable td:first-child { column per school — so each chip sits exactly over the column it labels.
position: sticky; The caption occupies the rail; chips flow into the school columns. */
left: 0; @media (min-width: 761px) {
z-index: 1; .schoolBar {
box-shadow: 2px 0 4px -2px rgba(26, 22, 18, 0.08); display: grid;
grid-template-columns: 200px repeat(var(--school-count, 3), 1fr);
gap: 0 0.75rem;
overflow-x: visible;
} }
.comparisonTable thead th:first-child { .schoolChip {
z-index: 2; min-width: 0;
} }
.comparisonTable tbody tr:hover td:first-child { .barCaption {
background: var(--bg-secondary, #f3ede4); grid-column: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.1rem;
padding-right: 0.5rem;
min-width: 0;
} }
.comparisonTable tbody tr:last-child td { .barCaptionEyebrow {
border-bottom: none; font-size: 0.72rem;
}
.comparisonTable tbody tr:hover {
background: var(--bg-secondary, #f3ede4);
}
.yearCell {
font-weight: 700;
color: var(--accent-gold, #c9a227);
}
/* Empty State */
.emptyState {
text-align: center;
padding: 4rem 2rem;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
}
.emptyStateTitle {
font-size: 1.5rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
}
.barCaptionCount {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
margin-bottom: 0.5rem; }
font-family: var(--font-playfair), 'Playfair Display', serif;
} }
.emptyStateDescription { .chipRemove {
font-size: 1rem; margin-left: auto;
color: var(--text-secondary, #5c564d); border: none;
max-width: 400px; background: var(--bg-secondary, #f3ede4);
margin: 0 auto 1.5rem; color: var(--text-muted, #6d685f);
border-radius: 50%;
width: 22px;
height: 22px;
cursor: pointer;
flex: none;
font-size: 0.9rem;
line-height: 1;
} }
.metricDescription { .footnote {
margin-top: 0.5rem; font-size: 0.78rem;
color: var(--text-muted, #6d685f);
margin-top: 2.5rem;
border-top: 1px solid var(--border-light, #e5dfd5);
padding-top: 1rem;
max-width: 75ch;
}
/* Mobile: the sticky school bar becomes compact, horizontally-scrollable
pills with short names (matching the mobile mockup) instead of full-width
cards whose names wrap to several lines. */
@media (max-width: 640px) {
/* The mobile Navigation header is shorter (≈57px). */
.schoolBar {
top: 57px;
}
.schoolChip {
flex: 0 0 auto;
min-width: 0;
border-top-width: 2px;
border-radius: 999px;
padding: 0.35rem 0.7rem;
box-shadow: none;
}
.chipName {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary); white-space: nowrap;
max-width: 600px;
flex-basis: 100%;
margin-top: 0.25rem;
} }
.progressNote { .chipNameFull {
background: var(--bg-secondary); display: none;
border-left: 3px solid var(--accent-teal);
padding: 0.75rem 1rem;
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
} }
.chipNameShort {
/* Responsive Design */ display: inline;
@media (max-width: 768px) {
.headerContent {
flex-direction: column;
align-items: stretch;
} }
.header h1 { .chipMeta {
font-size: 1.75rem; display: none;
} }
.metricSelector { .chipRemove {
flex-direction: column; width: 18px;
align-items: stretch; height: 18px;
padding: 1rem; font-size: 0.75rem;
border-radius: 8px;
}
.metricSelect {
max-width: 100%;
}
.schoolsGrid {
grid-template-columns: 1fr;
}
.chartSection,
.tableSection {
padding: 1rem;
border-radius: 8px;
}
.chartContainer {
height: 300px;
}
.comparisonTable {
font-size: 0.875rem;
}
.comparisonTable th,
.comparisonTable td {
padding: 0.75rem 0.5rem;
}
.latestValue {
margin-left: -1rem;
margin-right: -1rem;
margin-bottom: -1rem;
padding: 1rem;
border-radius: 0 0 8px 5px;
} }
} }
+233 -259
View File
@@ -1,49 +1,42 @@
/** /**
* ComparisonView Component * ComparisonView the parent-first compare screen: a sticky school bar and
* Client-side comparison interface with phase tabs, charts, and tables * six sections (At a glance / Ofsted / Academics / Getting a place / Who
* goes there / Explore trends), every number anchored against the England
* average or the computed state-school benchmark with provenance-correct
* labels. Layout and copy follow the reviewed mockups
* (docs/superpowers/specs/mockups/).
*/ */
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import dynamic from 'next/dynamic';
import { useComparison } from '@/hooks/useComparison'; import { useComparison } from '@/hooks/useComparison';
const ComparisonChart = dynamic(
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
{ ssr: false },
);
import { SchoolSearchModal } from './SchoolSearchModal'; import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton'; import { CompareAtAGlance } from './compare/CompareAtAGlance';
import type { ComparisonData, MetricDefinition, School } from '@/lib/types'; import { CompareOfsted } from './compare/CompareOfsted';
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils'; import { CompareAcademics } from './compare/CompareAcademics';
import { CompareAdmissions } from './compare/CompareAdmissions';
import { CompareCommunity } from './compare/CompareCommunity';
import { TrendsExplorer, PRIMARY_CATEGORIES, SECONDARY_CATEGORIES } from './compare/TrendsExplorer';
import type {
Benchmarks,
ComparisonData,
MetricDefinition,
NationalAverages,
School,
} from '@/lib/types';
import { CHART_COLORS, schoolUrl, shortName } from '@/lib/utils';
import { fetchComparison } from '@/lib/api'; import { fetchComparison } from '@/lib/api';
import { track } from '@/lib/analytics'; import { track } from '@/lib/analytics';
import styles from './ComparisonView.module.css'; import styles from './ComparisonView.module.css';
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
const SECONDARY_CATEGORIES = ['gcse'];
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'Expected Standard', category: 'expected' },
{ label: 'Higher Standard', category: 'higher' },
{ label: 'Progress Scores', category: 'progress' },
{ label: 'Average Scores', category: 'average' },
{ label: 'Gender Performance', category: 'gender' },
{ label: 'Equity (Disadvantaged)', category: 'equity' },
{ label: 'School Context', category: 'context' },
{ label: 'Absence', category: 'absence' },
{ label: '3-Year Trends', category: 'trends' },
];
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'GCSE Performance', category: 'gcse' },
];
interface ComparisonViewProps { interface ComparisonViewProps {
initialData: Record<string, ComparisonData> | null; initialData: Record<string, ComparisonData> | null;
initialNationalAverages?: NationalAverages;
initialBenchmarks?: Benchmarks;
initialUrns: number[]; initialUrns: number[];
metrics: MetricDefinition[]; metrics: MetricDefinition[];
selectedMetric: string; selectedMetric: string;
@@ -51,6 +44,8 @@ interface ComparisonViewProps {
export function ComparisonView({ export function ComparisonView({
initialData, initialData,
initialNationalAverages,
initialBenchmarks,
initialUrns, initialUrns,
metrics, metrics,
selectedMetric: initialMetric, selectedMetric: initialMetric,
@@ -58,11 +53,15 @@ export function ComparisonView({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison(); const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
const [selectedMetric, setSelectedMetric] = useState(initialMetric); const [selectedMetric, setSelectedMetric] = useState(initialMetric);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [comparisonData, setComparisonData] = useState(initialData); const [comparisonData, setComparisonData] = useState(initialData);
const [nationalAverages, setNationalAverages] = useState<NationalAverages | undefined>(
initialNationalAverages,
);
const [benchmarks, setBenchmarks] = useState<Benchmarks | undefined>(initialBenchmarks);
const [shareConfirm, setShareConfirm] = useState(false); const [shareConfirm, setShareConfirm] = useState(false);
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary'); const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
// Tracks whether the user has explicitly clicked a phase tab. // Tracks whether the user has explicitly clicked a phase tab.
@@ -77,24 +76,29 @@ export function ComparisonView({
if (!isInitialized) return; if (!isInitialized) return;
if (initialUrns.length > 0 && initialData) { if (initialUrns.length > 0 && initialData) {
const urlSchools = initialUrns const urlSchools = initialUrns
.map(urn => initialData[String(urn)]?.school_info) .map((urn) => initialData[String(urn)]?.school_info)
.filter((info): info is NonNullable<typeof info> => Boolean(info)); .filter((info): info is NonNullable<typeof info> => Boolean(info));
const sameSet = const sameSet =
urlSchools.length === selectedSchools.length && urlSchools.length === selectedSchools.length &&
urlSchools.every(s => selectedSchools.some(sel => sel.urn === s.urn)); urlSchools.every((s) => selectedSchools.some((sel) => sel.urn === s.urn));
if (urlSchools.length > 0 && !sameSet) { if (urlSchools.length > 0 && !sameSet) {
replaceSchools(urlSchools); replaceSchools(urlSchools);
} }
} }
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps // Re-seed when a client-side navigation lands on a different ?urns= set
// (initialUrns/initialData are new props on the same component instance).
}, [isInitialized, initialUrns.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
// Sync URL with selected schools const urnKey = selectedSchools.map((s) => s.urn).join(',');
// Sync the URL with the selection + metric. Pure navigation state — no
// fetching here: metric changes are presentational (the data is already
// client-side) and must not refire the comparison request.
useEffect(() => { useEffect(() => {
const urns = selectedSchools.map((s) => s.urn).join(',');
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
if (urns) { if (urnKey) {
params.set('urns', urns); params.set('urns', urnKey);
} else { } else {
params.delete('urns'); params.delete('urns');
} }
@@ -103,82 +107,114 @@ export function ComparisonView({
const newUrl = `${pathname}?${params.toString()}`; const newUrl = `${pathname}?${params.toString()}`;
router.replace(newUrl, { scroll: false }); router.replace(newUrl, { scroll: false });
}, [urnKey, selectedMetric, pathname, searchParams, router]);
// Fetch comparison data // Fetch when the school set changes, but only for schools we don't already
if (selectedSchools.length > 0) { // have data for. This skips the refetch of SSR-rendered data on load AND
fetchComparison(urns, { cache: 'no-store' }) // avoids a network call when a school is merely removed. A ref holds the
// latest data so the effect can read it without re-running on every fetch.
//
// Correctness note: we must NOT null the data on a transient empty urnKey.
// On mount the basket is empty for a beat before it hydrates from the URL,
// and blanking here (then skipping the refetch because SSR "covers" the set)
// was leaving the page empty on refresh. The render already shows the empty
// state whenever `selectedSchools` is empty, so stale data for deselected
// schools is harmless — it's simply unused.
const comparisonDataRef = useRef(comparisonData);
comparisonDataRef.current = comparisonData;
useEffect(() => {
if (!isInitialized || !urnKey) return;
const have = comparisonDataRef.current ?? {};
const covered = urnKey.split(',').every((urn) => have[urn] != null);
if (covered) return;
// Guard against out-of-order responses: while the basket hydrates from
// localStorage it can transiently hold a DIFFERENT school set than the
// URL, firing a fetch for schools the user is no longer comparing. That
// stale response must not replace data for the current set — it blanked
// every section until a hard refresh. Cleanup marks the run cancelled
// when urnKey moves on, so only the current selection's response is
// applied (replacing the map keeps it bounded and guarantees a re-added
// school is refetched fresh rather than served a lingering old entry).
let cancelled = false;
fetchComparison(urnKey, { cache: 'no-store' })
.then((data) => { .then((data) => {
if (cancelled) return;
setComparisonData(data.comparison); setComparisonData(data.comparison);
setNationalAverages(data.national_averages);
setBenchmarks(data.benchmarks);
}) })
.catch((err) => { .catch((err) => {
// Keep whatever we already have (SSR data or a previous fetch) rather
// than blanking the page — a transient refetch failure shouldn't
// destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err); console.error('Failed to fetch comparison:', err);
setComparisonData(null);
}); });
} else { return () => {
setComparisonData(null); cancelled = true;
}
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
// Classify schools by phase using comparison data
const classifySchool = (school: School): 'primary' | 'secondary' => {
const info = comparisonData?.[school.urn]?.school_info;
if (info?.attainment_8_score != null) return 'secondary';
if (info?.rwm_expected_pct != null) return 'primary';
// Fallback: check yearly data
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
return 'primary';
}; };
}, [urnKey, isInitialized]);
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary'); const primarySchools = selectedSchools.filter((school) => {
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary'); const info = comparisonData?.[school.urn]?.school_info;
const hasPrimaryData =
info?.rwm_expected_pct != null ||
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.rwm_expected_pct != null);
if (hasPrimaryData) return true;
return school.phase?.toLowerCase().includes('primary') || false;
});
// Auto-select tab with more schools and sync the metric to match the detected phase. const secondarySchools = selectedSchools.filter((school) => {
// This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct) const info = comparisonData?.[school.urn]?.school_info;
// but the shortlisted schools are secondary — the phase tab switches but the metric const hasSecondaryData =
// needs to follow, otherwise all secondary cards show "" for a primary-only field. info?.attainment_8_score != null ||
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.attainment_8_score != null);
if (hasSecondaryData) return true;
return school.phase?.toLowerCase().includes('secondary') || false;
});
// Auto-select tab with more schools and sync the metric to match the phase.
useEffect(() => { useEffect(() => {
if (!comparisonData || selectedSchools.length === 0) return; if (!comparisonData || selectedSchools.length === 0) return;
if (phaseLockedByUser.current) return; if (phaseLockedByUser.current) return;
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary'; const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
setComparePhase(newPhase); setComparePhase(newPhase);
// Only reset the metric when it doesn't belong to the newly detected phase.
// This preserves a correct metric that came from the URL (e.g. metric=attainment_8_score).
const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES; const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
const metricFitsPhase = metrics.some( const metricFitsPhase = metrics.some(
(m) => m.key === selectedMetric && phaseCategories.includes(m.category) (m) => m.key === selectedMetric && phaseCategories.includes(m.category),
); );
if (!metricFitsPhase) { if (!metricFitsPhase) {
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'); setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
} }
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps // selectedSchools is a dep because the basket hydrates after mount: the
// first run sees an empty basket and bails, so it must re-fire when the
// schools arrive. primarySchools/secondarySchools/metrics/selectedMetric
// are intentionally omitted (derived or would cause loops).
}, [comparisonData, selectedSchools]); // eslint-disable-line react-hooks/exhaustive-deps
const handlePhaseChange = (phase: 'primary' | 'secondary') => { const handlePhaseChange = (phase: 'primary' | 'secondary') => {
phaseLockedByUser.current = true; phaseLockedByUser.current = true;
setComparePhase(phase); setComparePhase(phase);
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'; setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
setSelectedMetric(defaultMetric);
}; };
// compare_viewed: fire once after the page has its first selection. // compare_viewed: fire once after the page has its first selection.
// We watch `selectedSchools.length` going from 0 → ≥1 so the event is
// sent only when there's actual content to view, not for empty arrivals.
const compareViewedRef = useRef(false); const compareViewedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (compareViewedRef.current) return; if (compareViewedRef.current) return;
if (selectedSchools.length === 0) return; if (selectedSchools.length === 0) return;
compareViewedRef.current = true; compareViewedRef.current = true;
const primaryCount = selectedSchools.filter(s => s.phase?.toLowerCase().includes('primary')).length; const primaryCount = selectedSchools.filter((s) =>
s.phase?.toLowerCase().includes('primary'),
).length;
const secondaryCount = selectedSchools.length - primaryCount; const secondaryCount = selectedSchools.length - primaryCount;
const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed'; const phaseMix =
primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix }); track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
}, [selectedSchools]); }, [selectedSchools]);
const handleMetricChange = (metric: string) => {
track('compare_metric_changed', { metric, phase: comparePhase });
setSelectedMetric(metric);
};
const handleRemoveSchool = (urn: number) => { const handleRemoveSchool = (urn: number) => {
removeSchool(urn); removeSchool(urn);
track('compare_school_removed', { urn, from: 'compare' }); track('compare_school_removed', { urn, from: 'compare' });
@@ -189,21 +225,22 @@ export function ComparisonView({
const count = selectedSchools.length; const count = selectedSchools.length;
const shareData = { const shareData = {
title: 'School comparison · SchoolCompare', title: 'School comparison · SchoolCompare',
text: count > 0 text:
count > 0
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare` ? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
: 'SchoolCompare', : 'SchoolCompare',
url, url,
}; };
// Prefer the native share sheet on platforms that support it (iOS / Android). if (
// canShare is feature-detected because Safari iOS exposes share() but typeof navigator !== 'undefined' &&
// some configurations refuse the payload. navigator.share &&
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) { (!navigator.canShare || navigator.canShare(shareData))
) {
try { try {
await navigator.share(shareData); await navigator.share(shareData);
track('compare_shared', { method: 'native', school_count: count }); track('compare_shared', { method: 'native', school_count: count });
return; return;
} catch (err) { } catch (err) {
// User cancelled — bail silently. Any other error falls through to clipboard.
if ((err as DOMException)?.name === 'AbortError') return; if ((err as DOMException)?.name === 'AbortError') return;
} }
} }
@@ -212,27 +249,22 @@ export function ComparisonView({
track('compare_shared', { method: 'clipboard', school_count: count }); track('compare_shared', { method: 'clipboard', school_count: count });
setShareConfirm(true); setShareConfirm(true);
setTimeout(() => setShareConfirm(false), 2000); setTimeout(() => setShareConfirm(false), 2000);
} catch { /* fallback: do nothing */ } } catch {
/* fallback: do nothing */
}
}; };
const isPrimary = comparePhase === 'primary'; const isPrimary = comparePhase === 'primary';
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
const activeSchools = isPrimary ? primarySchools : secondarySchools; const activeSchools = isPrimary ? primarySchools : secondarySchools;
// Get metric definition
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
const metricLabel = currentMetricDef?.label || selectedMetric;
// No schools selected
if (selectedSchools.length === 0) { if (selectedSchools.length === 0) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<header className={styles.header}> <header className={styles.header}>
<h1>Compare Schools</h1> <h1>Compare Schools</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
Add schools to your comparison basket to see side-by-side performance data Add schools to your comparison basket to see them side by side inspection results,
academics, admissions and community.
</p> </p>
</header> </header>
@@ -250,39 +282,46 @@ export function ComparisonView({
); );
} }
// Build filtered comparison data for active phase // Build filtered comparison data for the active phase
const activeComparisonData: Record<string, ComparisonData> = {}; const activeComparisonData: Record<string, ComparisonData> = {};
if (comparisonData) { if (comparisonData) {
activeSchools.forEach(s => { activeSchools.forEach((s) => {
if (comparisonData[s.urn]) { if (comparisonData[s.urn]) {
activeComparisonData[s.urn] = comparisonData[s.urn]; activeComparisonData[s.urn] = comparisonData[s.urn];
} }
}); });
} }
const hasData = Object.keys(activeComparisonData).length > 0;
// Get years for table
const years =
Object.keys(activeComparisonData).length > 0
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
: [];
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Header */}
<header className={styles.header}> <header className={styles.header}>
<div className={styles.headerContent}> <div className={styles.headerContent}>
<div> <div>
<h1>Compare Schools</h1> <h1>Compare Schools</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''} {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''} side by side
each number anchored against the England average so you can tell at a glance
what&apos;s typical and what stands out.
</p> </p>
</div> </div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}> <div className={styles.headerActions}>
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary"> <button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
+ Add School + Add School
</button> </button>
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link"> <button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg> <svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
width="16"
height="16"
>
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
{shareConfirm ? 'Copied!' : 'Share'} {shareConfirm ? 'Copied!' : 'Share'}
</button> </button>
</div> </div>
@@ -290,6 +329,7 @@ export function ComparisonView({
</header> </header>
{/* Phase Tabs */} {/* Phase Tabs */}
{secondarySchools.length > 0 && primarySchools.length > 0 && (
<div className={styles.phaseTabs}> <div className={styles.phaseTabs}>
<button <button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`} className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
@@ -304,6 +344,7 @@ export function ComparisonView({
Secondary ({secondarySchools.length}) Secondary ({secondarySchools.length})
</button> </button>
</div> </div>
)}
{activeSchools.length === 0 ? ( {activeSchools.length === 0 ? (
<EmptyState <EmptyState
@@ -316,183 +357,116 @@ export function ComparisonView({
/> />
) : ( ) : (
<> <>
{/* Metric Selector */} {/* Sticky school bar column identity while scrolling. On desktop
<section className={styles.metricSelector}> it shares the sections' grid template (via --school-count) so
<label htmlFor="metric-select" className={styles.metricLabel}> each chip sits exactly over the column it labels. */}
Select Metric: <div
</label> className={styles.schoolBar}
<select style={{ '--school-count': activeSchools.length } as CSSProperties}
id="metric-select" aria-label="Schools in this comparison"
value={selectedMetric}
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
> >
{optgroups.map(({ label, category }) => { {/* Fills the 200px label rail on desktop (hidden on mobile).
const groupMetrics = filteredMetrics.filter(m => m.category === category); All-through schools must not be miscounted as "primary
if (groupMetrics.length === 0) return null; schools"/"secondary schools" — mixed baskets get "· primary
return ( view" phrasing instead. */}
<optgroup key={category} label={label}> <div className={styles.barCaption}>
{groupMetrics.map((metric) => ( <span className={styles.barCaptionEyebrow}>Comparing</span>
<option key={metric.key} value={metric.key}>{metric.label}</option> <span className={styles.barCaptionCount}>
))} {activeSchools.every((sch) =>
</optgroup> sch.phase?.toLowerCase().includes(comparePhase),
); )
})} ? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
</select> : `${activeSchools.length} schools · ${comparePhase} view`}
{currentMetricDef?.description && ( </span>
<p className={styles.metricDescription}>{currentMetricDef.description}</p> </div>
)}
</section>
{/* Progress score explanation */}
{selectedMetric.includes('progress') && (
<p className={styles.progressNote}>
Progress scores measure pupils&apos; progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
</p>
)}
{/* School Cards */}
<section className={styles.schoolsSection}>
<div className={styles.schoolsGrid}>
{activeSchools.map((school, index) => ( {activeSchools.map((school, index) => (
<div <div
key={school.urn} key={school.urn}
className={styles.schoolCard} className={styles.schoolChip}
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }} style={{ borderTopColor: CHART_COLORS[index % CHART_COLORS.length] }}
> >
<span
className={styles.chipDot}
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<span className={styles.chipText}>
<a className={styles.chipName} href={schoolUrl(school.urn, school.school_name)}>
<span className={styles.chipNameFull}>{school.school_name}</span>
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
</a>
<span className={styles.chipMeta}>
{[
/all.?through/i.test(school.phase ?? '') ? 'All-through' : null,
school.local_authority,
school.school_type,
]
.filter(Boolean)
.join(' · ')}
</span>
</span>
<button <button
onClick={() => handleRemoveSchool(school.urn)} onClick={() => handleRemoveSchool(school.urn)}
className={styles.removeButton} className={styles.chipRemove}
aria-label={`Remove ${school.school_name}`} aria-label={`Remove ${school.school_name}`}
title="Remove from comparison" title="Remove from comparison"
> >
× ×
</button> </button>
<h2 className={styles.schoolName}>
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
</h2>
<div className={styles.schoolMeta}>
{school.local_authority && (
<span className={styles.metaItem}>{school.local_authority}</span>
)}
{school.school_type && (
<span className={styles.metaItem}>{school.school_type}</span>
)}
</div>
{/* Latest metric value */}
{activeComparisonData[school.urn] && (
<div className={styles.latestValue}>
<div className={styles.latestLabel}>{metricLabel}</div>
{/* Text uses the AA-dark variant; the swatch dot keeps the true series colour */}
<div className={styles.latestNumber} style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}>
<span
style={{
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
background: CHART_COLORS[index % CHART_COLORS.length],
marginRight: '0.4rem',
verticalAlign: 'middle',
}}
/>
{(() => {
const yearlyData = activeComparisonData[school.urn].yearly_data;
if (yearlyData.length === 0) return '-';
const latestData = yearlyData[yearlyData.length - 1];
const value = latestData[selectedMetric as keyof typeof latestData];
if (value === null || value === undefined) return '-';
if (selectedMetric.includes('progress')) {
return formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
return formatPercentage(value as number);
} else {
return typeof value === 'number' ? value.toFixed(1) : String(value);
}
})()}
</div>
</div>
)}
</div> </div>
))} ))}
</div> </div>
</section>
{/* Comparison Chart */} {hasData && (
{Object.keys(activeComparisonData).length > 0 ? ( <>
<section className={styles.chartSection}> <CompareAtAGlance
<h2 className={styles.sectionTitle}>Performance Over Time</h2> schools={activeSchools}
<div className={styles.chartContainer}> data={activeComparisonData}
<ComparisonChart nationalAverages={nationalAverages}
comparisonData={activeComparisonData} benchmarks={benchmarks}
isSecondary={!isPrimary}
/>
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
<CompareAcademics
schools={activeSchools}
data={activeComparisonData}
nationalAverages={nationalAverages}
benchmarks={benchmarks}
isSecondary={!isPrimary}
/>
<CompareAdmissions
schools={activeSchools}
data={activeComparisonData}
isSecondary={!isPrimary}
/>
<CompareCommunity
schools={activeSchools}
data={activeComparisonData}
benchmarks={benchmarks}
isSecondary={!isPrimary}
/>
<TrendsExplorer
schools={activeSchools}
data={activeComparisonData}
metrics={metrics}
metric={selectedMetric} metric={selectedMetric}
metricLabel={metricLabel} onMetricChange={setSelectedMetric}
isPrimaryPhase={isPrimary}
nationalAverages={nationalAverages}
/> />
</div>
</section>
) : activeSchools.length > 0 ? (
<section className={styles.chartSection}>
<LoadingSkeleton type="list" />
</section>
) : null}
{/* Comparison Table */} <p className={styles.footnote}>
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && ( Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection
<section className={styles.tableSection}> outcomes, DfE school admissions data, school census. England averages for test
<h2 className={styles.sectionTitle}>Detailed Comparison</h2> results are official DfE figures; other benchmarks are state-school averages
<div className={styles.tableWrapper}> computed from our dataset. Following DfE practice, figures based on 5 or fewer
<table className={styles.comparisonTable}> pupils are suppressed and shown as &quot;no data&quot;.
<thead> </p>
<tr> </>
<th>Year</th>
{activeSchools.map((school) => (
<th key={school.urn}>{school.school_name}</th>
))}
</tr>
</thead>
<tbody>
{years.map((year) => (
<tr key={year}>
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
{activeSchools.map((school) => {
const schoolData = activeComparisonData[school.urn];
if (!schoolData) return <td key={school.urn}>-</td>;
const yearData = schoolData.yearly_data.find((d) => d.year === year);
if (!yearData) return <td key={school.urn}>-</td>;
const value = yearData[selectedMetric as keyof typeof yearData];
if (value === null || value === undefined) {
return <td key={school.urn}>-</td>;
}
let displayValue: string;
if (selectedMetric.includes('progress')) {
displayValue = formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
displayValue = formatPercentage(value as number);
} else {
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
}
return <td key={school.urn}>{displayValue}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
)} )}
</> </>
)} )}
{/* School Search Modal */}
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} /> <SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div> </div>
); );
+102
View File
@@ -0,0 +1,102 @@
.row {
margin: 1.1rem 0 1.6rem;
}
.head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
}
.title {
font-weight: 600;
font-size: 0.95rem;
}
.headNote {
font-size: 0.8rem;
color: var(--text-muted);
}
.strip {
position: relative;
height: 34px;
margin-top: 0.45rem;
}
.track {
position: absolute;
left: 0;
right: 0;
top: 15px;
height: 4px;
border-radius: 2px;
background: var(--bg-secondary);
}
.anchorTick {
position: absolute;
top: 4px;
width: 2px;
height: 26px;
background: var(--text-muted);
}
.anchorLabel {
position: absolute;
top: -14px;
transform: translateX(-50%);
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
}
.point {
position: absolute;
top: 9px;
width: 16px;
height: 16px;
border-radius: 50%;
transform: translateX(-50%);
border: 2px solid var(--bg-card);
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
}
.pointLabel {
position: absolute;
top: 27px;
transform: translateX(-50%);
font-size: 0.72rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.pointLabelAbove {
top: -6px;
}
@media (max-width: 760px) {
.row {
margin: 0.9rem 0 1.3rem;
}
.title {
font-size: 0.82rem;
}
.strip {
height: 32px;
}
.point {
width: 14px;
height: 14px;
}
.pointLabel {
font-size: 0.64rem;
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* DotStrip the compare screen's signature element: one measure per strip,
* every school's dot on a shared track, anchored by a grey England-average
* tick so "right of the tick = above average" needs no domain knowledge.
*/
'use client';
import { stripPositions } from '@/lib/compareLogic';
import { CHART_COLORS, CHART_TEXT_COLORS } from '@/lib/utils';
import styles from './DotStrip.module.css';
export interface DotStripProps {
label: string;
/** One value per school; index = the school's chart-colour index. */
values: Array<number | null>;
schoolNames: string[];
/** Anchor tick, e.g. { value: 62, label: 'England 62%' }. Omit when the
* benchmark isn't available the caller should say why in `headNote`. */
anchor?: { value: number; label: string } | null;
min?: number;
max?: number;
unit?: string;
/** Tooltip on the measure label (plain-English definition). */
tip?: string;
/** Small note on the right of the header row (e.g. the tick legend). */
headNote?: string;
}
export function DotStrip({
label,
values,
schoolNames,
anchor = null,
min = 0,
max = 100,
unit = '%',
tip,
headNote,
}: DotStripProps) {
const points = stripPositions(values, min, max);
const span = max - min;
const anchorPos =
anchor != null
? Math.min(100, Math.max(0, ((anchor.value - min) / span) * 100))
: null;
const ariaParts = [
anchor ? `${anchor.label}` : null,
...points.map(
(p) => `${schoolNames[p.schoolIndex] ?? `School ${p.schoolIndex + 1}`} ${p.value}${unit}`,
),
].filter(Boolean);
return (
<div className={styles.row}>
<div className={styles.head}>
<span className={styles.title} title={tip}>
{label}
</span>
{headNote && <span className={styles.headNote}>{headNote}</span>}
</div>
<div className={styles.strip} role="img" aria-label={`${label}: ${ariaParts.join(', ')}`}>
<div className={styles.track} />
{anchorPos != null && anchor && (
<>
<span className={styles.anchorTick} style={{ left: `${anchorPos}%` }} />
<span className={styles.anchorLabel} style={{ left: `${anchorPos}%` }}>
{anchor.label}
</span>
</>
)}
{points.map((p) => (
<span key={p.schoolIndex}>
<span
className={styles.point}
style={{
left: `${p.pos}%`,
background: CHART_COLORS[p.schoolIndex % CHART_COLORS.length],
}}
title={`${schoolNames[p.schoolIndex] ?? ''}: ${p.value}${unit}`}
/>
<span
className={`${styles.pointLabel} ${p.labelAbove ? styles.pointLabelAbove : ''}`}
style={{
left: `${p.pos}%`,
color: CHART_TEXT_COLORS[p.schoolIndex % CHART_TEXT_COLORS.length],
}}
>
{p.value}
</span>
</span>
))}
</div>
</div>
);
}
@@ -36,6 +36,91 @@
margin-bottom: 0; margin-bottom: 0;
} }
.searchHint {
margin: 0.875rem 0 0;
font-size: 0.95rem;
color: var(--text-secondary, #5a554d);
text-align: center;
}
.searchHint strong {
color: var(--text-primary, #1a1612);
font-weight: 600;
}
@media (max-width: 600px) {
.searchHint {
font-size: 0.85rem;
text-align: left;
}
}
.nearMeRow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.nearMeBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.375rem;
background: var(--accent-teal, #2d7d7d);
color: #fff;
border: none;
border-radius: 999px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
font-family: inherit;
}
.nearMeBtn:hover:not(:disabled) {
background: #235f5f;
transform: translateY(-1px);
}
.nearMeBtn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.nearMeSpinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: nearMeSpin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes nearMeSpin {
to {
transform: rotate(360deg);
}
}
.geoError {
font-size: 0.8125rem;
color: var(--accent-coral-dark, #b04a2e);
margin: 0;
max-width: 340px;
text-align: center;
}
@media (max-width: 600px) {
.nearMeBtn {
width: 100%;
justify-content: center;
}
}
.searchSection { .searchSection {
margin-bottom: 0; margin-bottom: 0;
} }
+61 -3
View File
@@ -11,9 +11,21 @@ interface FilterBarProps {
filters: Filters; filters: Filters;
isHero?: boolean; isHero?: boolean;
resultFilters?: ResultFilters; resultFilters?: ResultFilters;
// Geolocation "use my location" affordance, shown beside the hero search box.
// The state and handler live in HomeView (which owns the geolocation flow).
onNearMe?: () => void;
geoState?: "idle" | "requesting" | "error";
geoError?: string | null;
} }
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) { export function FilterBar({
filters,
isHero,
resultFilters,
onNearMe,
geoState = "idle",
geoError,
}: FilterBarProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -182,6 +194,52 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
{isPending ? <div className={styles.spinner}></div> : "Search"} {isPending ? <div className={styles.spinner}></div> : "Search"}
</button> </button>
</div> </div>
{isHero && (
<>
<p className={styles.searchHint}>
Search by <strong>school name</strong> or use your{" "}
<strong>postcode</strong> for the nearest schools.
</p>
{onNearMe && (
<div className={styles.nearMeRow}>
<button
type="button"
className={styles.nearMeBtn}
onClick={onNearMe}
disabled={geoState === "requesting"}
>
{geoState === "requesting" ? (
<>
<span className={styles.nearMeSpinner} aria-hidden="true" />
Locating you
</>
) : (
<>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z" />
<circle cx="12" cy="9" r="2.5" />
</svg>
Use my location
</>
)}
</button>
{geoError && (
<p className={styles.geoError} role="alert">
{geoError}
</p>
)}
</div>
)}
</>
)}
</form> </form>
{!isHero && ( {!isHero && (
@@ -310,8 +368,8 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
disabled={isPending} disabled={isPending}
> >
<option value="">With or without sixth form</option> <option value="">With or without sixth form</option>
<option value="yes">With sixth form (11-18)</option> <option value="yes">With sixth form</option>
<option value="no">Without sixth form (11-16)</option> <option value="no">Without sixth form</option>
</select> </select>
{admissionsPolicyOptions.length > 0 && ( {admissionsPolicyOptions.length > 0 && (
+10 -62
View File
@@ -369,6 +369,16 @@
.viewToggle { .viewToggle {
justify-content: center; justify-content: center;
flex-shrink: 0;
}
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
& Maths %"), which overflows a phone viewport beside the view toggle it
ran off the right edge. Let it flex into the remaining space and shrink;
the selected label truncates instead of pushing past the screen. */
.sortSelect {
flex: 1;
min-width: 0;
} }
.mapViewContainer { .mapViewContainer {
@@ -496,68 +506,6 @@
} }
} }
.discoverySection {
padding: 0.5rem 0 0.5rem;
text-align: center;
}
.nearMeRow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.nearMeBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.375rem;
background: var(--accent-teal, #2d7d7d);
color: #fff;
border: none;
border-radius: 999px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
font-family: inherit;
}
.nearMeBtn:hover:not(:disabled) {
background: #235f5f;
transform: translateY(-1px);
}
.nearMeBtn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.nearMeBtnSpinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: nearMeSpin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes nearMeSpin {
to { transform: rotate(360deg); }
}
.geoError {
font-size: 0.8125rem;
color: var(--accent-coral-dark, #b04a2e);
margin: 0;
max-width: 340px;
text-align: center;
}
.quickSearches { .quickSearches {
display: flex; display: flex;
align-items: center; align-items: center;
+3 -29
View File
@@ -284,37 +284,11 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
filters={filters} filters={filters}
isHero={!isSearchActive} isHero={!isSearchActive}
resultFilters={initialSchools.result_filters} resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
/> />
{/* Discovery section shown on landing page before any search */}
{!isSearchActive && initialSchools.schools.length === 0 && (
<div className={styles.discoverySection}>
<div className={styles.nearMeRow}>
<button
className={styles.nearMeBtn}
onClick={handleNearMe}
disabled={geoState === 'requesting'}
>
{geoState === 'requesting' ? (
<>
<span className={styles.nearMeBtnSpinner} aria-hidden="true" />
Locating you
</>
) : (
<>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z"/>
<circle cx="12" cy="9" r="2.5"/>
</svg>
Schools near me
</>
)}
</button>
{geoError && <p className={styles.geoError} role="alert">{geoError}</p>}
</div>
</div>
)}
{/* Admissions countdown strip — only on landing page */} {/* Admissions countdown strip — only on landing page */}
{!isSearchActive && ( {!isSearchActive && (
<section className={styles.admissionsStrip}> <section className={styles.admissionsStrip}>
+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>}
+47 -34
View File
@@ -10,12 +10,14 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset } from 'chart.js'; 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 { track } from '@/lib/analytics'; import { track } from '@/lib/analytics';
import styles from './PerformanceChart.module.css'; import styles from './PerformanceChart.module.css';
@@ -34,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';
@@ -66,30 +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));
// ── Mobile detection ───────────────────────────────────────────────── // Gap-honest year axis: every academic year between the first and last data
// Hydration-safe: SSR renders desktop; client flips to mobile after mount. // point appears, so cancelled/unpublished years (2019/20, 2020/21, and — for
const [isMobile, setIsMobile] = useState(false); // KS2 — 2021/22) render as real gaps rather than compressed time. School
useEffect(() => { // lines never bridge these gaps (spanGaps:false below).
const mq = window.matchMedia('(max-width: 640px)'); const axisYears = fillAcademicYears(sortedData.map(d => d.year));
const update = () => setIsMobile(mq.matches); const byYear = new Map(sortedData.map(d => [d.year, d]));
update(); const col = (key: keyof SchoolResult): (number | null)[] =>
mq.addEventListener('change', update); axisYears.map(y => {
return () => mq.removeEventListener('change', update); const v = byYear.get(y)?.[key];
}, []); return typeof v === 'number' ? v : null;
});
const years = axisYears.map(formatAcademicYear);
// ── Build per-year national averages ───────────────────────────────── const isMobile = useIsMobile();
const natRefRwm: (number | null)[] = sortedData.map(d => {
// ── Build per-year national averages (aligned to the filled axis) ────
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;
@@ -115,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 = {
@@ -128,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,
@@ -145,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,
@@ -155,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,
@@ -173,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,
@@ -184,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,
@@ -200,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,
@@ -211,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,
@@ -222,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,
@@ -283,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',
@@ -334,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 },
@@ -361,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 },
}, },
}, },
}; };
@@ -424,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>
)} )}
@@ -713,18 +713,6 @@
margin: -0.5rem 0 1rem; margin: -0.5rem 0 1rem;
} }
/* Response count badge */
.responseBadge {
font-size: 0.75rem;
font-weight: 500;
font-family: var(--font-dm-sans), sans-serif;
color: var(--text-muted, #8a847a);
background: var(--bg-secondary, #f3ede4);
padding: 0.1rem 0.5rem;
border-radius: 999px;
margin-left: auto;
}
.subSectionTitle { .subSectionTitle {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
@@ -732,18 +720,6 @@
margin: 1.25rem 0 0.75rem; margin: 1.25rem 0 0.75rem;
} }
/* Parent recommendation line in Ofsted section */
.parentRecommendLine {
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
margin: 0.5rem 0 0;
}
.parentRecommendLine strong {
color: var(--accent-teal, #2d7d7d);
font-weight: 700;
}
/* Metrics Grid & Cards */ /* Metrics Grid & Cards */
.metricsGrid { .metricsGrid {
display: grid; display: grid;
@@ -1064,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);
@@ -1094,49 +1105,6 @@
text-decoration: underline; text-decoration: underline;
} }
/* Parent View */
.parentViewGrid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.parentViewRow {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.parentViewLabel {
flex: 0 0 18rem;
color: var(--text-secondary, #5c564d);
font-size: 0.8125rem;
}
.parentViewBar {
flex: 1;
height: 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
overflow: hidden;
}
.parentViewFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px;
transition: width 0.4s ease;
}
.parentViewPct {
flex: 0 0 2.75rem;
text-align: right;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* Admissions badge — uses unified status colours */ /* Admissions badge — uses unified status colours */
.admissionsBadge { .admissionsBadge {
display: inline-flex; display: inline-flex;
@@ -1269,25 +1237,6 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.parentViewRow {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.parentViewLabel {
flex: none;
max-width: 100%;
}
.parentViewBar {
width: 100%;
}
.parentViewPct {
flex: none;
}
.card { .card {
padding: 1rem; padding: 1rem;
} }
@@ -1629,3 +1578,18 @@
.historyDisclosure[open] > .historyToggle::before { .historyDisclosure[open] > .historyToggle::before {
transform: rotate(90deg); transform: rotate(90deg);
} }
/* GIAS "Open, but proposed to close" notice strip */
.closingStrip {
background: #fdf6e3;
border-left: 4px solid #e2c96f;
border-radius: 0 6px 6px 0;
padding: 0.55rem 0.9rem;
margin: 0.5rem 0;
font-size: 0.88rem;
color: #6e5a00;
max-width: 68ch;
}
.closingStrip strong {
color: #8a6200;
}
+200 -115
View File
@@ -13,14 +13,16 @@ import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
import { MetricTooltip } from './MetricTooltip'; import { MetricTooltip } from './MetricTooltip';
import type { import type {
School, SchoolResult, AbsenceData, School, SchoolResult, AbsenceData,
OfstedInspection, OfstedParentView, SchoolCensus, OfstedInspection, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics, SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { import {
formatPercentage, formatProgress, formatAcademicYear, 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),
@@ -63,7 +65,6 @@ interface SchoolDetailViewProps {
yearlyData: SchoolResult[]; yearlyData: SchoolResult[];
absenceData: AbsenceData | null; absenceData: AbsenceData | null;
ofsted: OfstedInspection | null; ofsted: OfstedInspection | null;
parentView: OfstedParentView | null;
census: SchoolCensus | null; census: SchoolCensus | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[]; admissionsHistory: SchoolAdmissions[];
@@ -75,7 +76,7 @@ interface SchoolDetailViewProps {
export function SchoolDetailView({ export function SchoolDetailView({
schoolInfo, yearlyData, absenceData, schoolInfo, yearlyData, absenceData,
ofsted, parentView, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance, ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
}: SchoolDetailViewProps) { }: SchoolDetailViewProps) {
const router = useRouter(); const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison(); const { addSchool, removeSchool, isSelected } = useComparison();
@@ -86,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);
@@ -154,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);
@@ -223,19 +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 (parentView && parentView.total_responses != null && parentView.total_responses > 0)
navItems.push({ id: 'parents', label: 'Parents' });
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' });
@@ -272,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 ?? '';
@@ -312,10 +352,19 @@ 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>
)} )}
</div> </div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
check with the local authority before applying.
</div>
)}
{schoolInfo.address && ( {schoolInfo.address && (
<p className={styles.address}> <p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
@@ -504,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
@@ -522,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>
@@ -549,11 +598,6 @@ export function SchoolDetailView({
) : null; ) : null;
})} })}
</div> </div>
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</p>
)}
</> </>
) : ( ) : (
/* ── Old OEIF layout ── */ /* ── Old OEIF layout ── */
@@ -570,28 +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>
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</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}`]}`}>
@@ -610,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}>
@@ -630,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}
@@ -639,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>
)} )}
@@ -652,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}
@@ -661,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 && (
@@ -692,28 +740,34 @@ 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,
}, },
{ {
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,
}, },
{ {
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,
}, },
]} ]}
/> />
)}
{/* 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) && (
@@ -759,6 +813,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}>
@@ -767,8 +824,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>
)} )}
@@ -791,8 +848,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>
)} )}
@@ -803,8 +860,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>
)} )}
@@ -868,6 +925,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 */}
@@ -909,6 +975,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 */}
@@ -944,7 +1013,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 && (
@@ -960,7 +1029,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>
)} )}
@@ -977,7 +1046,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>
)} )}
@@ -1041,16 +1110,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>
@@ -1059,7 +1164,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>
@@ -1081,7 +1194,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>
@@ -1106,8 +1227,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}>
@@ -1129,42 +1250,6 @@ export function SchoolDetailView({
</section> </section>
)} )}
{/* What Parents Say */}
{parentView && parentView.total_responses != null && parentView.total_responses > 0 && (
<section id="parents" className={styles.card}>
<h2 className={styles.sectionTitle}>
What Parents Say
<span className={styles.responseBadge}>
{parentView.total_responses.toLocaleString()} responses
</span>
</h2>
<p className={styles.sectionSubtitle}>
From the Ofsted Parent View survey parents share their experience of this school.
</p>
<div className={styles.parentViewGrid}>
{[
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
].filter(q => q.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.parentViewRow}>
<span className={styles.parentViewLabel}>{label}</span>
<div className={styles.parentViewBar}>
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
</div>
))}
</div>
</section>
)}
{/* School Life */} {/* School Life */}
{hasSchoolLife && ( {hasSchoolLife && (
<section id="school-life" className={styles.card}> <section id="school-life" className={styles.card}>
@@ -1185,7 +1270,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>
)} )}
@@ -1197,7 +1282,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>
)} )}
@@ -10,6 +10,15 @@
height: 100dvh; height: 100dvh;
} }
/* Fallback fullscreen (iOS Safari no Element.requestFullscreen): the API
can't promote the element, so pin it over the page ourselves. Above the
comparison toast (3000) and the bottom nav; below modals (9999+). */
.mapWrapper.fsFallback {
position: fixed;
inset: 0;
z-index: 5000;
}
.fullscreenBtn { .fullscreenBtn {
position: absolute; position: absolute;
top: 0.625rem; top: 0.625rem;
+38 -8
View File
@@ -33,22 +33,52 @@ interface SchoolMapProps {
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) { export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [nativeFullscreen, setNativeFullscreen] = useState(false);
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
// overlay driven by state instead of the Fullscreen API.
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
const isFullscreen = nativeFullscreen || fallbackFullscreen;
// Sync state with browser fullscreen events (e.g. Escape key) // Sync state with browser fullscreen events (e.g. Escape key)
useEffect(() => { useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); const onFsChange = () => setNativeFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange); return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []); }, []);
// Lock body scroll while the fallback overlay is up.
useEffect(() => {
if (!fallbackFullscreen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [fallbackFullscreen]);
// Leaflet re-measures on window resize (trackResize). Native fullscreen fires
// one; the CSS fallback overlay changes size without a resize event, so nudge
// Leaflet after the layout settles or the map fills only part of the screen.
useEffect(() => {
const id = requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
return () => cancelAnimationFrame(id);
}, [isFullscreen]);
const toggleFullscreen = useCallback(() => { const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) { if (document.fullscreenElement) {
wrapperRef.current?.requestFullscreen(); document.exitFullscreen().catch(() => {});
} else { return;
document.exitFullscreen();
} }
}, []); if (fallbackFullscreen) {
setFallbackFullscreen(false);
return;
}
const el = wrapperRef.current;
if (!el) return;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
} else {
setFallbackFullscreen(true);
}
}, [fallbackFullscreen]);
// Calculate center if not provided // Calculate center if not provided
const mapCenter: [number, number] = center || (() => { const mapCenter: [number, number] = center || (() => {
@@ -64,7 +94,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
})(); })();
return ( return (
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''}`}> <div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''} ${fallbackFullscreen ? styles.fsFallback : ''}`}>
<button <button
className={styles.fullscreenBtn} className={styles.fullscreenBtn}
onClick={toggleFullscreen} onClick={toggleFullscreen}
@@ -254,3 +254,10 @@
justify-content: center; justify-content: center;
} }
} }
/* GIAS "Open, but proposed to close" marker */
.attrClosing {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
}
+22 -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 } 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 = () => {
@@ -78,15 +91,18 @@ export function SchoolRow({
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>} {school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>} {showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
{showGender && <span className={styles.attr}>{school.gender}</span>} {showGender && <span className={styles.attr}>{school.gender}</span>}
{isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}> Proposed to close</span>
)}
</div> </div>
{/* Line 3: Key stats */} {/* Line 3: Key stats */}
<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>
@@ -383,17 +383,6 @@
margin: 1.25rem 0 0.75rem; margin: 1.25rem 0 0.75rem;
} }
.responseBadge {
font-size: 0.75rem;
font-weight: 500;
font-family: var(--font-dm-sans), sans-serif;
color: var(--text-muted, #8a847a);
background: var(--bg-secondary, #f3ede4);
padding: 0.1rem 0.5rem;
border-radius: 999px;
margin-left: auto;
}
/* ── Progress 8 suspension banner ───────────────────── */ /* ── Progress 8 suspension banner ───────────────────── */
.p8Banner { .p8Banner {
background: rgba(180, 120, 0, 0.1); background: rgba(180, 120, 0, 0.1);
@@ -634,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);
@@ -664,60 +688,6 @@
text-decoration: underline; text-decoration: underline;
} }
/* ── Parent View ─────────────────────────────────────── */
.parentRecommendLine {
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
margin: 0.5rem 0 0;
}
.parentRecommendLine strong {
color: var(--accent-teal, #2d7d7d);
font-weight: 700;
}
.parentViewGrid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.parentViewRow {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.parentViewLabel {
flex: 0 0 18rem;
color: var(--text-secondary, #5c564d);
font-size: 0.8125rem;
}
.parentViewBar {
flex: 1;
height: 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
overflow: hidden;
}
.parentViewFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px;
transition: width 0.4s ease;
}
.parentViewPct {
flex: 0 0 2.75rem;
text-align: right;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* ── Admissions ──────────────────────────────────────── */ /* ── Admissions ──────────────────────────────────────── */
.admissionsTypeBadge { .admissionsTypeBadge {
border-radius: 6px; border-radius: 6px;
@@ -1114,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 {
@@ -1135,10 +1109,6 @@
font-size: 1rem; font-size: 1rem;
} }
.parentViewLabel {
flex-basis: 10rem;
}
.ofstedReportLink { .ofstedReportLink {
margin-left: 0; margin-left: 0;
display: block; display: block;
@@ -1151,25 +1121,6 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.parentViewRow {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.parentViewLabel {
flex: none;
max-width: 100%;
}
.parentViewBar {
width: 100%;
}
.parentViewPct {
flex: none;
}
.metricsGrid { .metricsGrid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 0.5rem; gap: 0.5rem;
@@ -1187,3 +1138,18 @@
padding: 0.75rem; padding: 0.75rem;
} }
} }
/* GIAS "Open, but proposed to close" notice strip */
.closingStrip {
background: #fdf6e3;
border-left: 4px solid #e2c96f;
border-radius: 0 6px 6px 0;
padding: 0.55rem 0.9rem;
margin: 0.5rem 0;
font-size: 0.88rem;
color: #6e5a00;
max-width: 68ch;
}
.closingStrip strong {
color: #8a6200;
}
@@ -19,12 +19,13 @@ const PerformanceChart = dynamic(
); );
import type { import type {
School, SchoolResult, AbsenceData, School, SchoolResult, AbsenceData,
OfstedInspection, OfstedParentView, SchoolCensus, OfstedInspection, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics, SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange } 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';
@@ -65,7 +66,6 @@ interface SecondarySchoolDetailViewProps {
yearlyData: SchoolResult[]; yearlyData: SchoolResult[];
absenceData: AbsenceData | null; absenceData: AbsenceData | null;
ofsted: OfstedInspection | null; ofsted: OfstedInspection | null;
parentView: OfstedParentView | null;
census: SchoolCensus | null; census: SchoolCensus | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
senDetail: SenDetail | null; senDetail: SenDetail | null;
@@ -76,7 +76,7 @@ interface SecondarySchoolDetailViewProps {
export function SecondarySchoolDetailView({ export function SecondarySchoolDetailView({
schoolInfo, yearlyData, schoolInfo, yearlyData,
ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData, ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
}: SecondarySchoolDetailViewProps) { }: SecondarySchoolDetailViewProps) {
const router = useRouter(); const router = useRouter();
// Hero map — the "View on map" link opens its fullscreen view. // Hero map — the "View on map" link opens its fullscreen view.
@@ -99,15 +99,25 @@ export function SecondarySchoolDetailView({
const secondaryAvg = nationalAvg?.secondary ?? {}; const secondaryAvg = nationalAvg?.secondary ?? {};
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false; // GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
const hasFinance = finance != null && finance.per_pupil_spend != null; const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasParents = parentView != null && parentView.total_responses != null && parentView.total_responses > 0;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null; const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation; const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
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';
@@ -159,7 +169,6 @@ export function SecondarySchoolDetailView({
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' }); if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' }); if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
if (hasParents) navItems.push({ id: 'parents', label: 'Parents' });
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' }); if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -188,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;
@@ -239,6 +257,12 @@ export function SecondarySchoolDetailView({
</span> </span>
)} )}
</div> </div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
check with the local authority before applying.
</div>
)}
{schoolInfo.address && ( {schoolInfo.address && (
<p className={styles.address}> <p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
@@ -328,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
@@ -345,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>
@@ -386,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}`]}`}>
@@ -418,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 },
@@ -435,11 +453,6 @@ export function SecondarySchoolDetailView({
</div> </div>
</> </>
)} )}
{hasParents && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView!.q_recommend_pct!)}%</strong> of parents would recommend this school ({parentView!.total_responses!.toLocaleString()} responses)
</p>
)}
</section> </section>
)} )}
@@ -450,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>
)} )}
@@ -469,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}
@@ -478,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>
)} )}
@@ -509,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}
@@ -518,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>
)} )}
@@ -531,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}
@@ -540,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}>
@@ -740,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>
</> </>
@@ -775,42 +793,6 @@ export function SecondarySchoolDetailView({
</details> </details>
</section> </section>
)} )}
{/* ── Parent View ────────────────────────────────── */}
{hasParents && parentView && (
<section id="parents" className={styles.card}>
<h2 className={styles.sectionTitle}>
What Parents Say
<span className={styles.responseBadge}>
{parentView.total_responses!.toLocaleString()} responses
</span>
</h2>
<p className={styles.sectionSubtitle}>
From the Ofsted Parent View survey parents share their experience of this school.
</p>
<div className={styles.parentViewGrid}>
{[
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
].filter(q => q.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.parentViewRow}>
<span className={styles.parentViewLabel}>{label}</span>
<div className={styles.parentViewBar}>
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
</div>
))}
</div>
</section>
)}
{/* ── Wellbeing ──────────────────────────────────── */} {/* ── Wellbeing ──────────────────────────────────── */}
{hasWellbeing && ( {hasWellbeing && (
<section id="wellbeing" className={styles.card}> <section id="wellbeing" className={styles.card}>
@@ -266,3 +266,9 @@
justify-content: center; justify-content: center;
} }
} }
.closingTag {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
}
+13 -3
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 } 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 {
@@ -23,7 +23,8 @@ function detectAdmissionsTag(school: School): string | null {
} }
function hasSixthForm(school: School): boolean { function hasSixthForm(school: School): boolean {
return school.age_range?.includes('18') ?? false; // GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
return school.has_sixth_form ?? false;
} }
interface SecondarySchoolRowProps { interface SecondarySchoolRowProps {
@@ -54,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);
@@ -96,6 +103,9 @@ export function SecondarySchoolRow({
{admissionsTag} {admissionsTag}
</span> </span>
)} )}
{isProposedToClose(school) && (
<span className={`${styles.provisionTag} ${styles.closingTag}`}> Proposed to close</span>
)}
</div> </div>
{/* Line 3: KS4 stats */} {/* Line 3: KS4 stats */}
@@ -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>
);
}
@@ -0,0 +1,18 @@
.moreMeasures {
margin-top: 0.5rem;
border-top: 1px solid var(--border-light);
padding-top: 0.75rem;
}
.moreMeasures summary {
cursor: pointer;
font-weight: 600;
font-size: 0.88rem;
color: var(--accent-coral-dark);
}
.stripNote {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0.5rem 0 0;
}
@@ -0,0 +1,385 @@
/**
* How children do academically tier-1 dot strips anchored on official
* England averages, tier-2 "More measures" one tap away, equity row against
* the computed state-school benchmark. Copy verbatim from the reviewed
* mockups; teacher-assessed measures are labelled as such.
*/
'use client';
import { latestValues, verdict } from '@/lib/compareLogic';
import { isSpecialSchool } from '@/lib/utils';
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
import { DotStrip } from '@/components/DotStrip';
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
import styles from './CompareAcademics.module.css';
interface StripSpec {
label: string;
metric: string;
anchorKey?: string;
tip?: string;
min?: number;
max?: number;
unit?: string;
}
const TIER1_PRIMARY: StripSpec[] = [
{
label: 'Reading, writing & maths — expected standard',
metric: 'rwm_expected_pct',
anchorKey: 'rwm_expected_pct',
tip: '% of Year 6 pupils reaching the expected standard in reading, writing and maths.',
},
{ label: 'Reading', metric: 'reading_expected_pct', anchorKey: 'reading_expected_pct' },
{
label: 'Writing (teacher-assessed)',
metric: 'writing_expected_pct',
anchorKey: 'writing_expected_pct',
tip: 'Writing is assessed by teachers, not tested.',
},
{ label: 'Maths', metric: 'maths_expected_pct', anchorKey: 'maths_expected_pct' },
{
label: 'Working at a higher standard than expected',
metric: 'rwm_high_pct',
anchorKey: 'rwm_high_pct',
tip: 'A high score in the reading and maths tests plus “greater depth” in teacher-assessed writing.',
},
];
const TIER2_PRIMARY: StripSpec[] = [
{
label: 'Grammar, punctuation & spelling — expected standard',
metric: 'gps_expected_pct',
anchorKey: 'gps_expected_pct',
},
{
label: 'Science — expected standard (teacher-assessed)',
metric: 'science_expected_pct',
anchorKey: 'science_expected_pct',
tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.',
},
{
label: 'Average scaled score — reading',
metric: 'reading_avg_score',
anchorKey: 'reading_avg_score',
min: 100,
max: 120,
unit: '',
},
{
label: 'Average scaled score — maths',
metric: 'maths_avg_score',
anchorKey: 'maths_avg_score',
min: 100,
max: 120,
unit: '',
},
{
label: 'Average scaled score — grammar, punctuation & spelling',
metric: 'gps_avg_score',
anchorKey: 'gps_avg_score',
min: 100,
max: 120,
unit: '',
},
];
function Strip({
spec,
data,
urns,
schoolNames,
national,
special,
}: {
spec: StripSpec;
data: Record<string, ComparisonData>;
urns: number[];
schoolNames: string[];
national: Record<string, number> | undefined;
/** Per-school special-school flag; special schools' mainstream attainment is
* not a fair comparison, so it's dropped from the strip (no dot). */
special: boolean[];
}) {
const values = latestValues(data, urns, spec.metric).map((v, i) =>
v != null && !special[i] ? Math.round(v) : null,
);
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
const anchor =
anchorValue != null
? { value: anchorValue, label: `England ${Math.round(anchorValue)}${spec.unit ?? '%'}` }
: null;
if (values.every((v) => v == null)) return null;
return (
<DotStrip
label={spec.label}
values={values}
schoolNames={schoolNames}
anchor={anchor}
min={spec.min ?? 0}
max={spec.max ?? 100}
unit={spec.unit ?? '%'}
tip={spec.tip}
/>
);
}
export function CompareAcademics({
schools,
data,
nationalAverages,
benchmarks,
isSecondary: propIsSecondary,
}: {
schools: School[];
data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages;
benchmarks?: Benchmarks;
isSecondary?: boolean;
}) {
const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name);
// Special schools / PRUs / AP: their pupils sit the same assessments but very
// few reach the mainstream standard, so their attainment isn't a fair
// like-for-like comparison — drop it (progress banding, which IS meaningful,
// is kept).
const specialFlags = schools.map((school) => isSpecialSchool(school));
const dropSpecial = (vals: Array<number | null>) =>
vals.map((v, i) => (specialFlags[i] ? null : v));
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
if (isSecondary) {
const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score'));
const banding = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].progress_8_banding) return rows[i].progress_8_banding as string;
}
return null;
});
// DfE stopped publishing Progress 8 from 2024/25: those GCSE year groups
// 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 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 (
<Section
title="How students do academically"
how="GCSE results (latest year). Attainment 8 averages performance across eight subjects; Progress 8 shows how much progress students make compared with similar students nationally — the wording is DfE's own banding."
>
<SectionGrid schools={schools}>
<RowLabel tip="Average Attainment 8 score across eight GCSE subjects.">Attainment 8</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{att8[i] != null ? (
<>
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>{' '}
{anchorChip(att8[i], att8Anchor, 2)}
{att8Anchor != null && (
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
))}
<RowLabel tip="DfE's own plain-English Progress 8 label.">Progress 8</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{banding[i] ? (
<Chip
tone={
/well above|above/i.test(banding[i] as string)
? 'good'
: /well below|below/i.test(banding[i] as string)
? 'warn'
: 'neutral'
}
>
{banding[i]}
</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>
)}
</Cell>
))}
<RowLabel tip="% achieving grade 5 or above in both English and maths GCSEs.">
Grade 5+ in English &amp; maths
</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{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>
))}
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
{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>
))}
</SectionGrid>
</Section>
);
}
const national = nationalAverages?.primary;
const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct'));
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
// groups where single pupils move the percentage — show roughly how many
// 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 (
<Section
title="How children do academically"
how="Results from national tests and teacher assessments at the end of Year 6 — writing is assessed by teachers, not tested. Each line runs from 0100%; the grey tick marks the England average, so dots to its right are above average."
>
<div className={s.card}>
{TIER1_PRIMARY.map((spec) => (
<Strip
key={spec.metric}
spec={spec}
data={data}
urns={urns}
schoolNames={schoolNames}
national={national}
special={specialFlags}
/>
))}
<details className={styles.moreMeasures}>
<summary>More measures grammar, punctuation &amp; spelling, science, average scaled scores</summary>
{TIER2_PRIMARY.map((spec) => (
<Strip
key={spec.metric}
spec={spec}
data={data}
urns={urns}
schoolNames={schoolNames}
national={national}
special={specialFlags}
/>
))}
<p className={styles.stripNote}>
The scaled-score strips show the 100120 window of the full 80120 range; 100 is the
expected standard. Where an England tick is missing, the official figure isn&apos;t in
our dataset yet.
</p>
</details>
</div>
{disadvantaged.some((v) => v != null) && (
<SectionGrid schools={schools}>
<RowLabel tip="% of disadvantaged pupils (free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. Benchmark computed across state schools in our dataset. Based on smaller pupil groups, so a single pupil can move a school's figure noticeably.">
Children from lower-income families
</RowLabel>
{schools.map((school, i) => {
const value = disadvantaged[i];
return (
<Cell key={school.urn} school={school} index={i}>
{value != null ? (
<>
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(value)}%
</span>{' '}
{cohorts[i] != null && (
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
)}{' '}
{disadvantagedAnchor != null && (
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
`Well above the ${Math.round(disadvantagedAnchor)}% state-school average`}
{verdict(value, disadvantagedAnchor, 5) === 'close' &&
`Around the ${Math.round(disadvantagedAnchor)}% state-school average`}
{verdict(value, disadvantagedAnchor, 5) === 'below' &&
`Below the ${Math.round(disadvantagedAnchor)}% state-school average`}
</Chip>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</SectionGrid>
)}
</Section>
);
}
@@ -0,0 +1,154 @@
/**
* Getting a place admissions framed the way the expert review requires:
* total applications are "named on N forms" (any preference rank, not
* head-to-head), one consistent chip metric (first-preference success),
* equal-preference and offers-vs-intake explanations up front.
*/
'use client';
import { admissionsForPhase, summariseAdmissions } from '@/lib/compareLogic';
import type { ComparisonData, School } from '@/lib/types';
import { CHART_COLORS } from '@/lib/utils';
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
export function CompareAdmissions({
schools,
data,
isSecondary = false,
}: {
schools: School[];
data: Record<string, ComparisonData>;
isSecondary?: boolean;
}) {
// Admissions rounds are phase-specific: an all-through school's Year 7
// round must never stand in for Reception on the primary tab (and vice
// versa) — beside pure primaries it reads as Reception odds.
const rows = schools.map((school) => admissionsForPhase(data[String(school.urn)], isSecondary));
const roundLabel = isSecondary ? 'Year 7' : 'Reception';
const anyData = rows.some(Boolean);
const entryYear = rows.find(Boolean)?.year;
const entryLabel = entryYear
? `September ${String(entryYear).slice(0, 4)} entry`
: 'the most recent admissions round';
if (!anyData) {
return (
<Section
title="Getting a place"
how={`No ${roundLabel} admissions data is available for these schools yet.`}
>
<></>
</Section>
);
}
return (
<Section
title="Getting a place"
how={
<>
From the most recent admissions round ({entryLabel}). &quot;First choice&quot; means
families who ranked the school top of their application form officially a &quot;first
preference&quot;. Schools never see your ranking: places are decided only by the
school&apos;s admission criteria, so listing a school lower down never hurts your chances.
These are National Offer Day offers waiting lists and appeals can change the final
intake.
</>
}
>
<SectionGrid schools={schools}>
<Measure
tip="How many application forms named the school at any preference rank — not the number of families competing head-to-head for a place."
label="Interest in the school"
>
{schools.map((school, i) => {
const a = rows[i];
return (
<Cell key={school.urn} school={school} index={i}>
{a?.total_applications != null && a?.places_offered != null ? (
<>
Named on <strong>{a.total_applications.toLocaleString('en-GB')}</strong> forms ·{' '}
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
</>
) : (
<span className={s.small}>
We don&apos;t hold {roundLabel} admissions data for this school
</span>
)}
</Cell>
);
})}
</Measure>
<Measure label="First-choice families offered a place">
{schools.map((school, i) => {
const summary = summariseAdmissions(rows[i]);
return (
<Cell key={school.urn} school={school} index={i}>
{summary.firstPrefPct != null ? (
<>
<strong>{summary.firstPrefPct}%</strong>{' '}
{summary.chip && summary.chip.tone === 'warn' && (
<Chip tone="warn">{summary.chip.text}</Chip>
)}
<span className={s.barMini}>
<i
style={{
width: `${summary.firstPrefPct}%`,
background: CHART_COLORS[i % CHART_COLORS.length],
}}
/>
</span>
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</Measure>
<Measure label="What this means">
{schools.map((school, i) => {
const a = rows[i];
const summary = summariseAdmissions(a);
const info = data[String(school.urn)]?.school_info;
const selective = (info?.admissions_policy ?? '').toLowerCase() === 'selective';
const faith =
!!info?.religious_denomination &&
!/^(none|does not apply|not applicable)$/i.test(info.religious_denomination);
let text: string | null = null;
if (summary.firstPrefPct != null) {
if (selective) {
// Selective schools: the entrance test decides, whatever the
// offer percentage looks like — never the distance template.
text =
'Entry is by entrance test — the school is selective; distance and preference rank dont decide places.';
} else if (summary.firstPrefPct >= 100) {
text = `Every family who put ${school.school_name} first got a place.`;
} else if (summary.firstPrefPct >= 90) {
text = `Nearly every family who put ${school.school_name} first got a place.`;
} else if (a?.oversubscribed) {
text = isSecondary
? faith
? 'More first-choice applications than places — check the schools admission criteria (faith-based criteria may apply).'
: 'More first-choice applications than places — check the schools admission criteria (catchment or distance often decides, but criteria vary).'
: 'More first-choice applications than places — check the schools admission criteria (for most non-faith primaries, distance decides).';
} else {
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
}
}
return (
<Cell key={school.urn} school={school} index={i}>
{text ? <span className={s.small}>{text}</span> : <span className={s.small}></span>}
</Cell>
);
})}
</Measure>
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,204 @@
/**
* At a glance the short version of every section below it. Copy verbatim
* from the reviewed mockups. Report-card cells summarise by counting graded
* areas (best first) and always NAME problem areas; safeguarding is a
* separate line, never a count.
*/
'use client';
import {
admissionsForPhase,
latestValues,
ofstedDisplay,
summariseAdmissions,
verdict,
type ReportCardSummary,
} from '@/lib/compareLogic';
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
function ReportCardChips({ summary }: { summary: ReportCardSummary }) {
return (
<>
<strong style={{ fontSize: '0.9rem' }}>Report card</strong>
<span className={s.chipStack}>
{summary.counts.map((c) => (
<Chip key={c.label} tone={c.label === 'Expected standard' ? 'neutral' : 'good'}>
{c.count} area{c.count === 1 ? '' : 's'} {c.label}
</Chip>
))}
{summary.problems.map((p) => (
<Chip key={p.areaLabel} tone={p.label === 'Urgent improvement' ? 'bad' : 'warn'}>
{p.areaLabel}: {p.label}
</Chip>
))}
</span>
<span className={s.small}>
{summary.allClear && 'No areas need attention · '}
{summary.safeguarding === 'met' && 'Safeguarding met'}
{summary.safeguarding === 'not_met' && 'Safeguarding not met'}
</span>
</>
);
}
export function CompareAtAGlance({
schools,
data,
nationalAverages,
benchmarks,
isSecondary: propIsSecondary,
}: {
schools: School[];
data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages;
benchmarks?: Benchmarks;
isSecondary?: boolean;
}) {
const urns = schools.map((school) => school.urn);
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
const headlineKey = isSecondary ? 'attainment_8_score' : 'rwm_expected_pct';
const headlineValues = latestValues(data, urns, headlineKey);
const anchor = isSecondary
? nationalAverages?.secondary?.attainment_8_score
: nationalAverages?.primary?.rwm_expected_pct;
const medianPupils = isSecondary
? benchmarks?.secondary?.median_pupils
: benchmarks?.primary?.median_pupils;
return (
<Section title="At a glance" how="The short version — each row below is explained in its own section further down.">
<SectionGrid schools={schools}>
<Measure label="Latest Ofsted inspection">
{schools.map((school, i) => {
const display = ofstedDisplay(data[String(school.urn)]?.ofsted);
return (
<Cell key={school.urn} school={school} index={i}>
{display.kind === 'report_card' && <ReportCardChips summary={display.summary} />}
{(display.kind === 'graded' || display.kind === 'carried_forward') && (
<>
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
{display.gradeLabel}
</span>
{display.carriedForward && <span className={s.small}>Grade carried forward</span>}
</>
)}
{display.kind === 'transitional' && (
<>
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
No overall grade
</span>
<span className={s.small}>Sub-judgements only</span>
</>
)}
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
</Cell>
);
})}
</Measure>
<Measure
tip={
isSecondary
? 'Average Attainment 8 score across GCSE subjects (latest year).'
: '% of Year 6 pupils reaching the expected standard in reading, writing and maths (latest year).'
}
label={isSecondary ? 'Attainment 8 score' : 'Children reaching the expected standard'}
>
{schools.map((school, i) => {
const value = headlineValues[i];
return (
<Cell key={school.urn} school={school} index={i}>
{value != null ? (
<>
<span className={s.big}>{isSecondary ? value.toFixed(1) : `${Math.round(value)}%`}</span>{' '}
{anchor != null && (
<Chip
tone={
verdict(value, anchor) === 'above'
? 'good'
: verdict(value, anchor) === 'below'
? 'warn'
: 'neutral'
}
>
{verdict(value, anchor) === 'above' && 'Above England average'}
{verdict(value, anchor) === 'close' && 'Close to England average'}
{verdict(value, anchor) === 'below' && 'Below England average'}
</Chip>
)}
{anchor != null && (
<span className={s.small}>
England average {isSecondary ? anchor.toFixed(1) : `${Math.round(anchor)}%`}
</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</Measure>
<Measure label="Getting a place">
{schools.map((school, i) => {
// Phase-matched round only — an all-through school's Year 7 round
// must not masquerade as Reception odds on the primary tab.
const summary = summariseAdmissions(
admissionsForPhase(data[String(school.urn)], isSecondary),
);
return (
<Cell key={school.urn} school={school} index={i}>
{summary.chip ? (
<>
<Chip tone={summary.chip.tone}>{summary.chip.text}</Chip>
{summary.interest && <span className={s.small}>{summary.interest}</span>}
</>
) : (
<span className={s.small}>No admissions data</span>
)}
</Cell>
);
})}
</Measure>
<Measure label="Size">
{schools.map((school, i) => {
const census = data[String(school.urn)]?.census;
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;
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';
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.9) sizeNote = 'Smaller than average';
else sizeNote = 'About average size';
}
return (
<Cell key={school.urn} school={school} index={i}>
{pupils != null ? (
<>
{pupils.toLocaleString('en-GB')} pupils
{sizeNote && <span className={s.small}>{sizeNote}</span>}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</Measure>
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,215 @@
/**
* Who goes there the school's community from the latest census plus GIAS
* facts. Benchmark chips use the computed state-school averages and must
* carry their provenance wording (never "England average" for computed
* figures). Copy verbatim from the reviewed mockups.
*/
'use client';
import { verdict } from '@/lib/compareLogic';
import type { Benchmarks, ComparisonData, School } from '@/lib/types';
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
function pctSplit(part: number | null | undefined, total: number | null | undefined): string | null {
if (part == null || total == null || total === 0) return null;
return `${Math.round((part / total) * 100)}%`;
}
export function CompareCommunity({
schools,
data,
benchmarks,
isSecondary: propIsSecondary,
}: {
schools: School[];
data: Record<string, ComparisonData>;
benchmarks?: Benchmarks;
isSecondary?: boolean;
}) {
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary;
const fsmChip = (value: number | null) => {
// FSM is anchored only against a real FSM benchmark (census-sourced,
// pupil-weighted). disadvantaged_pct is a different measure (FSM6+CLA)
// — never fall back across definitions; no anchor means no chip.
const anchor = bench?.fsm_pct ?? null;
if (value == null || anchor == null) return null;
const v = verdict(value, anchor, 3);
return (
<Chip tone="neutral">
{v === 'above' && `Above the state-school average (${Math.round(anchor)}%)`}
{v === 'close' && `About the state-school average (${Math.round(anchor)}%)`}
{v === 'below' && `Below the state-school average (${Math.round(anchor)}%)`}
</Chip>
);
};
const anyAllThrough = schools.some((school) => /all.?through/i.test(school.phase ?? ''));
return (
<Section
title="Who goes there"
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}>
<Measure label="Pupils on roll">
{schools.map((school, i) => {
const info = data[String(school.urn)]?.school_info as (School & { gias_total_pupils?: number | null; capacity?: number | null }) | undefined;
const census = data[String(school.urn)]?.census;
const pupils = census?.total_pupils ?? info?.gias_total_pupils ?? null;
const capacity = info?.capacity ?? null;
let capNote: string | null = null;
if (pupils != null && capacity != null && capacity > 0) {
capNote =
pupils >= capacity
? `${capacity.toLocaleString('en-GB')} places — at or above capacity`
: `of ${capacity.toLocaleString('en-GB')} places (${Math.round((pupils / capacity) * 100)}% full)`;
}
return (
<Cell key={school.urn} school={school} index={i}>
{pupils != null ? (
<>
{pupils.toLocaleString('en-GB')}
{capNote && <span className={s.small}>{capNote}</span>}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</Measure>
<Measure label="Girls / boys">
{schools.map((school, i) => {
const census = data[String(school.urn)]?.census;
const girls = pctSplit(census?.female_pupils, census?.total_pupils);
const boys = pctSplit(census?.male_pupils, census?.total_pupils);
return (
<Cell key={school.urn} school={school} index={i}>
{girls && boys ? `${girls} / ${boys}` : <span className={s.small}>No data</span>}
</Cell>
);
})}
</Measure>
<Measure
tip="% of pupils eligible for free school meals — a common measure of how many pupils come from lower-income families. Benchmark computed across state schools in our dataset."
label="Free school meals"
>
{schools.map((school, i) => {
const fsm = data[String(school.urn)]?.census?.fsm_pct ?? null;
return (
<Cell key={school.urn} school={school} index={i}>
{fsm != null ? (
<>
{Math.round(fsm)}% {fsmChip(fsm)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</Measure>
<Measure
tip="% of pupils whose first language is known or believed to be other than English. State-school average computed from our dataset."
label="English as an additional language"
>
{schools.map((school, i) => {
const eal = data[String(school.urn)]?.census?.eal_pct ?? null;
return (
<Cell key={school.urn} school={school} index={i}>
{eal != null ? `${Math.round(eal)}%` : <span className={s.small}>No data</span>}
</Cell>
);
})}
</Measure>
<Measure
tip="% of pupils receiving SEN support (not including EHC plans). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign. State-school average computed from our dataset."
label="Extra learning support (SEN)"
>
{schools.map((school, i) => {
const rows = data[String(school.urn)]?.yearly_data ?? [];
let sen: number | null = null;
for (let r = rows.length - 1; r >= 0; r--) {
if (rows[r].sen_support_pct != null) {
sen = rows[r].sen_support_pct;
break;
}
}
const high =
sen != null && bench?.sen_support_pct != null && sen >= bench.sen_support_pct * 1.75;
return (
<Cell key={school.urn} school={school} index={i}>
{sen != null ? (
<>
{Math.round(sen)}% {high && <Chip tone="neutral">Well above average</Chip>}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell>
);
})}
</Measure>
<Measure label="Faith character">
{schools.map((school, i) => {
const info = data[String(school.urn)]?.school_info;
const faith = info?.religious_denomination;
const none = !faith || faith === 'Does not apply' || faith === 'None';
return (
<Cell key={school.urn} school={school} index={i}>
{none ? 'None' : faith}
</Cell>
);
})}
</Measure>
<Measure label="Ages">
{schools.map((school, i) => {
const info = data[String(school.urn)]?.school_info;
return (
<Cell key={school.urn} school={school} index={i}>
{info?.age_range || <span className={s.small}>No data</span>}
</Cell>
);
})}
</Measure>
<Measure label="Run by">
{schools.map((school, i) => {
const info = data[String(school.urn)]?.school_info;
const trust = info?.trust_name;
const la = info?.local_authority ?? school.local_authority;
return (
<Cell key={school.urn} school={school} index={i}>
{trust ? trust : la ? `${la} council` : <span className={s.small}>No data</span>}
</Cell>
);
})}
</Measure>
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,261 @@
/**
* Ofsted section one visual grammar for inspection detail across all
* three regimes (legacy graded, interim carried-forward, renewed-framework
* report card). Copy comes verbatim from the reviewed mockups.
*/
'use client';
import {
OFSTED_LEGACY_GRADES,
ofstedDisplay,
rcAreaLabel,
type OfstedDisplay,
} from '@/lib/compareLogic';
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
const GRADE_TONE: Record<number, 'good' | 'warn' | 'bad'> = {
1: 'good',
2: 'good',
3: 'warn',
4: 'bad',
};
const RC_CODE_TONE = (code: number): 'good' | 'warn' | 'bad' | 'neutral' =>
code <= 2 ? 'good' : code === 3 ? 'neutral' : code === 4 ? 'warn' : 'bad';
function formatInspectionDate(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
function yearsSince(iso: string | null): number | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
}
function ResultCell({ display }: { display: OfstedDisplay }) {
if (display.kind === 'none') {
return <span className={s.small}>No inspection outcome in our dataset</span>;
}
if (display.kind === 'report_card') {
return (
<>
<strong style={{ fontSize: '0.9rem' }}>Report card</strong>
<span className={s.small}>New-style inspection no overall grade is given</span>
</>
);
}
if (display.kind === 'transitional') {
return (
<>
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
No overall grade
</span>
<span className={s.small}>
Inspected under transitional framework (sub-judgements only)
</span>
</>
);
}
return (
<>
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
{display.gradeLabel}
</span>
<span className={s.small}>
{display.carriedForward
? 'Grade carried forward from an earlier inspection (ungraded visit since)'
: 'Overall grade (older-style inspection)'}
</span>
</>
);
}
function JudgementDetailCell({
ofsted,
display,
schoolName,
}: {
ofsted: OfstedInspection;
display: OfstedDisplay;
schoolName: string;
}) {
if (display.kind === 'report_card') {
const entries = Object.entries(ofsted.report_card ?? {});
return (
<div className={s.rcList}>
{entries.map(([key, entry]) => (
<div key={key} className={s.rcRow}>
<span className={s.rcArea}>{rcAreaLabel(key)}</span>
<Chip tone={RC_CODE_TONE(entry.code)}>{entry.label}</Chip>
</div>
))}
{ofsted.rc_safeguarding_met != null && (
<div className={s.rcRow}>
<span className={s.rcArea}>Safeguarding</span>
<Chip tone={ofsted.rc_safeguarding_met ? 'good' : 'bad'}>
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
</Chip>
</div>
)}
</div>
);
}
const legacyAreas: Array<[string, number | null | undefined]> = [
['Quality of education', ofsted.quality_of_education],
['Behaviour & attitudes', ofsted.behaviour_attitudes],
['Personal development', ofsted.personal_development],
['Leadership & management', ofsted.leadership_management],
['Early years provision', ofsted.early_years_provision],
['Sixth form provision', ofsted.sixth_form_provision],
];
// Only real Ofsted grades (14) are judgements. The MI file uses sentinel
// codes for "not applicable / no judgement" (9, and 0/8 variants) — those
// must never render as a rating chip.
const published = legacyAreas.filter(
(entry): entry is [string, number] =>
entry[1] != null && entry[1] >= 1 && entry[1] <= 4,
);
if (published.length === 0) {
return (
<span className={s.small}>
We don&apos;t hold area-by-area detail for this inspection see {schoolName}&apos;s
Ofsted page for the full report.
</span>
);
}
return (
<div className={s.rcList}>
{published.map(([label, grade]) => (
<div key={label} className={s.rcRow}>
<span className={s.rcArea}>{label}</span>
<Chip tone={GRADE_TONE[grade as number] ?? 'neutral'}>
{OFSTED_LEGACY_GRADES[grade as number] ?? String(grade)}
</Chip>
</div>
))}
</div>
);
}
export function CompareOfsted({
schools,
data,
}: {
schools: School[];
data: Record<string, ComparisonData>;
}) {
const displays = schools.map((school) => ofstedDisplay(data[String(school.urn)]?.ofsted));
const kinds = new Set(displays.map((d) => d.kind).filter((k) => k !== 'none'));
const mixedRegimes = kinds.size > 1;
return (
<Section
title="Ofsted inspection"
how={
<>
Ofsted is the schools inspectorate. It stopped giving a single overall grade in{' '}
<strong>September 2024</strong>; inspections between then and November 2025 kept the
area-by-area judgements without an overall grade, and from <strong>November 2025</strong>{' '}
new inspections produce a <strong>report card</strong> rating each area of school life on
a five-point scale.
{mixedRegimes && (
<> A report card and an older overall grade aren&apos;t directly comparable.</>
)}{' '}
(Ofsted&apos;s &quot;Expected standard&quot; rating is unrelated to the KS2 &quot;expected
standard&quot; test measure further down this page.)
</>
}
>
<SectionGrid schools={schools}>
<Measure label="Result">
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
<ResultCell display={displays[i]} />
</Cell>
))}
</Measure>
<Measure label="Inspected">
{schools.map((school, i) => {
const ofsted = data[String(school.urn)]?.ofsted;
// A report card is dated by its OWN inspection date. The legacy
// inspection_date belongs to an older inspection and must never
// be shown against a report card (report cards exist only from
// Nov 2025).
const dateIso =
displays[i].kind === 'report_card'
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
const age = yearsSince(dateIso);
return (
<Cell key={school.urn} school={school} index={i}>
{formatInspectionDate(dateIso)}{' '}
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
</Cell>
);
})}
</Measure>
<Measure
tip="Older-style inspections: one rating per judgement area, where published. New-style inspections: the full report card, one rating per area of school life."
label="Judgement detail"
>
{schools.map((school, i) => {
const ofsted = data[String(school.urn)]?.ofsted;
return (
<Cell key={school.urn} school={school} index={i}>
{ofsted ? (
<JudgementDetailCell
ofsted={ofsted}
display={displays[i]}
schoolName={school.school_name}
/>
) : (
<span className={s.small}>No inspection in our dataset</span>
)}
</Cell>
);
})}
</Measure>
<Measure
tip="Links to the school's page on ofsted.gov.uk, where all its inspection reports are listed."
label="Ofsted page"
>
{schools.map((school, i) => {
const url =
data[String(school.urn)]?.ofsted?.ofsted_page_url ??
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
return (
<Cell key={school.urn} school={school} index={i}>
{/* Short visible label to save space on mobile (the coloured
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>
</Cell>
);
})}
</Measure>
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,69 @@
.explore {
margin-top: 1rem;
}
.explore summary {
cursor: pointer;
font-weight: 600;
color: var(--accent-coral-dark);
padding: 0.85rem 1.1rem;
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.explore[open] summary {
border-radius: 8px 8px 0 0;
}
.inner {
border: 1px solid var(--border-light);
border-top: none;
border-radius: 0 0 8px 8px;
background: var(--bg-card);
padding: 1.25rem 1.5rem;
}
.picker {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.picker label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
}
.picker select {
font-family: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.6rem;
border-radius: 8px;
border: 1px solid var(--border-light);
background: var(--bg-card);
color: var(--text-primary);
max-width: 100%;
}
.desc {
font-size: 0.78rem;
color: var(--text-muted);
}
.progressNote {
font-size: 0.8rem;
color: var(--text-muted);
margin: 0 0 1rem;
}
/* ComparisonChart owns its own canvas height now (a definite px value per
breakpoint), with the mobile chip legend above and the gap note below it
flowing at natural size. This box therefore only needs to not constrain
that height no fixed height, or the note would again eat the plot. */
.chartBox {
min-height: 0;
}
@@ -0,0 +1,129 @@
/**
* Explore trends the full grouped metric catalogue (nothing from the old
* compare page is lost; spec §4's tier 3) driving the year-by-year chart with
* its England reference line. Matches the mockup: a measure picker and the
* chart only (no data table).
*/
'use client';
import dynamic from 'next/dynamic';
import type { ComparisonData, MetricDefinition, NationalAverages, School } from '@/lib/types';
import { track } from '@/lib/analytics';
import { Section } from './sectionShared';
import styles from './TrendsExplorer.module.css';
const ComparisonChart = dynamic(
() => import('../ComparisonChart').then((m) => m.ComparisonChart),
{ ssr: false },
);
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'Expected Standard', category: 'expected' },
{ label: 'Higher Standard', category: 'higher' },
{ label: 'Progress Scores', category: 'progress' },
{ label: 'Average Scores', category: 'average' },
{ label: 'Gender Performance', category: 'gender' },
{ label: 'Equity (Disadvantaged)', category: 'equity' },
{ label: 'School Context', category: 'context' },
{ label: 'Absence', category: 'absence' },
{ label: '3-Year Trends', category: 'trends' },
];
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
{ label: 'GCSE Performance', category: 'gcse' },
];
export const PRIMARY_CATEGORIES = PRIMARY_OPTGROUPS.map((g) => g.category);
export const SECONDARY_CATEGORIES = SECONDARY_OPTGROUPS.map((g) => g.category);
export function TrendsExplorer({
schools,
data,
metrics,
metric,
onMetricChange,
isPrimaryPhase,
nationalAverages,
}: {
schools: School[];
data: Record<string, ComparisonData>;
metrics: MetricDefinition[];
/** Controlled: the page owns the metric so the URL contract survives. */
metric: string;
onMetricChange: (metric: string) => void;
isPrimaryPhase: boolean;
nationalAverages?: NationalAverages;
}) {
const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category));
const metricDef = metrics.find((m) => m.key === metric);
const metricLabel = metricDef?.label || metric;
const nationalByYear: Record<number, number | null> = {};
for (const entry of nationalAverages?.by_year ?? []) {
const block = isPrimaryPhase ? entry.primary : entry.secondary;
nationalByYear[entry.year] = block?.[metric] ?? null;
}
const handleMetricChange = (next: string) => {
track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' });
onMetricChange(next);
};
return (
<Section
title="Explore trends"
how="The full year-by-year explorer for any measure, with the England average as a dashed reference line where official figures exist."
>
<details className={styles.explore} open>
<summary>Year-by-year trends</summary>
<div className={styles.inner}>
<div className={styles.picker}>
<label htmlFor="trends-metric-select">Measure:</label>
<select
id="trends-metric-select"
value={metric}
onChange={(e) => handleMetricChange(e.target.value)}
>
{optgroups.map(({ label, category }) => {
const group = filteredMetrics.filter((m) => m.category === category);
if (group.length === 0) return null;
return (
<optgroup key={category} label={label}>
{group.map((m) => (
<option key={m.key} value={m.key}>
{m.label}
</option>
))}
</optgroup>
);
})}
</select>
{metricDef?.description && <span className={styles.desc}>{metricDef.description}</span>}
</div>
{metric.includes('progress') && (
<p className={styles.progressNote}>
Progress scores measure pupils&apos; progress from KS1 to KS2. A score of 0 equals the
national average. DfE stopped publishing KS2 progress after 2022/23 (no KS1 baseline).
</p>
)}
<div className={styles.chartBox}>
<ComparisonChart
comparisonData={data}
schools={schools}
metric={metric}
metricLabel={metricLabel}
nationalByYear={nationalByYear}
isSecondary={!isPrimaryPhase}
/>
</div>
</div>
</details>
</Section>
);
}
@@ -0,0 +1,267 @@
/* Shared layout for the compare screen's measure-first sections.
Mobile base: each row-label becomes a measure header and each school cell
stacks under it (colour-coded via the cell's ::before school tag).
Desktop (761px): the mockups' grid 200px row-label column + one column
per school (24 columns supported via --school-count). */
.section {
margin-top: 3rem;
}
.sectionTitle {
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
font-size: 1.45rem;
font-weight: 700;
margin: 0;
padding-left: 0.75rem;
border-left: 3px solid var(--accent-coral-dark);
}
.how {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0.35rem 0 0 0.95rem;
max-width: 70ch;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 0;
margin-top: 1.25rem;
}
/* Mobile base: each measure is a card; each cell is a school row led by a
colour dot + short name. `display: contents` at 761px dissolves the card
back into the shared grid. */
.measure {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: 12px;
box-shadow: var(--shadow-soft);
padding: 0.75rem 0.85rem;
margin-bottom: 0.6rem;
}
.rowLabel {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0 0 0.1rem;
}
.cell {
display: flex;
align-items: baseline;
gap: 0.35rem 0.5rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--border-light);
margin-top: 0.5rem;
font-size: 0.95rem;
}
/* The school name gets its own full-width line above the value real
school names are long and varied, so a fixed-width name column truncated
them ("Our Lady Queen of H…") or crowded the value. */
.cellTag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-basis: 100%;
/* 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;
color: var(--sc, var(--text-secondary));
margin-bottom: 0.2rem;
}
.cellDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--dot, var(--text-muted));
flex: none;
}
.big {
font-size: 1.05rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.small {
display: block;
flex-basis: 100%;
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0;
}
.chip {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
border-radius: 999px;
padding: 0.15rem 0.6rem;
white-space: nowrap;
}
.chipGood {
background: rgba(45, 125, 125, 0.14);
color: var(--accent-teal);
}
.chipWarn {
background: var(--accent-gold-bg);
color: var(--accent-gold-text);
}
.chipBad {
background: var(--accent-coral-bg);
color: var(--accent-coral-dark);
}
.chipNeutral {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.help {
display: inline-flex;
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid var(--text-muted);
color: var(--text-muted);
font-size: 0.65rem;
align-items: center;
justify-content: center;
cursor: help;
flex: none;
}
.badge {
display: inline-block;
font-weight: 700;
border-radius: 6px;
padding: 0.25rem 0.7rem;
font-size: 0.9rem;
}
.badgeGood {
background: rgba(45, 125, 125, 0.14);
color: var(--accent-teal);
}
.badgeWarn {
background: var(--accent-gold-bg);
color: var(--accent-gold-text);
}
.badgeBad {
background: var(--accent-coral-bg);
color: var(--accent-coral-dark);
}
.rcList {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.2rem;
}
.rcRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
}
.rcArea {
color: var(--text-secondary);
}
.chipStack {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
flex-basis: 100%;
margin-top: 0.3rem;
}
.barMini {
display: block;
height: 8px;
border-radius: 4px;
background: var(--bg-secondary);
overflow: hidden;
margin-top: 0.3rem;
max-width: 140px;
}
.barMini > i {
display: block;
height: 100%;
border-radius: 4px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: 16px;
box-shadow: var(--shadow-soft);
padding: 1.25rem 1.5rem;
margin-top: 1rem;
}
.link {
color: var(--accent-coral-dark);
}
@media (min-width: 761px) {
.grid {
grid-template-columns: 200px repeat(var(--school-count, 3), 1fr);
gap: 0 0.75rem;
}
/* Dissolve the per-measure card so its label + cells become grid items of
.grid, keeping columns aligned across every measure. */
.measure {
display: contents;
}
.cellTag {
display: none;
}
.rowLabel {
color: var(--text-secondary);
padding: 0.85rem 0.5rem 0.85rem 0;
border-bottom: 1px solid var(--border-light);
}
.cell {
display: block;
padding: 0.85rem 0.25rem;
border-top: none;
border-bottom: 1px solid var(--border-light);
margin-top: 0;
}
.big {
font-size: 1.35rem;
}
.small {
flex-basis: auto;
padding-left: 0;
margin-top: 0.1rem;
}
}
@@ -0,0 +1,130 @@
/**
* Small shared pieces for the compare sections: the section shell, the
* row-label + per-school-cell grid, and tone-mapped chips. Copy passed into
* these comes verbatim from the reviewed mockups
* (docs/superpowers/specs/mockups/) do not paraphrase it here.
*/
'use client';
import type { CSSProperties, ReactNode } from 'react';
import type { School } from '@/lib/types';
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
import styles from './compareSections.module.css';
export function Section({
title,
how,
children,
}: {
title: string;
how?: ReactNode;
children: ReactNode;
}) {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{title}</h2>
{how && <p className={styles.how}>{how}</p>}
{children}
</section>
);
}
export function SectionGrid({
schools,
children,
}: {
schools: School[];
children: ReactNode;
}) {
return (
<div
className={styles.grid}
style={{ '--school-count': schools.length } as CSSProperties}
>
{children}
</div>
);
}
export function RowLabel({ children, tip }: { children: ReactNode; tip?: string }) {
return (
<div className={styles.rowLabel}>
{children}
{tip && (
<span className={styles.help} title={tip} aria-label={tip}>
?
</span>
)}
</div>
);
}
/**
* One measure = its row label plus a cell per school. `display: contents` on
* desktop (see CSS) makes these flow into the section grid as if this wrapper
* weren't here, keeping columns aligned across measures; on mobile the wrapper
* becomes a card so each measure reads as its own block.
*/
export function Measure({
label,
tip,
children,
}: {
label: ReactNode;
tip?: string;
children: ReactNode;
}) {
return (
<div className={styles.measure}>
<RowLabel tip={tip}>{label}</RowLabel>
{children}
</div>
);
}
export function Cell({
school,
index,
children,
}: {
school: School;
index: number;
children: ReactNode;
}) {
return (
<div
className={styles.cell}
style={
{
'--sc': CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length],
'--dot': CHART_COLORS[index % CHART_COLORS.length],
} as CSSProperties
}
>
{/* Mobile-only per-school tag (dot + short name); hidden on desktop,
where the column header identifies the school. */}
<span className={styles.cellTag}>
<span className={styles.cellDot} aria-hidden="true" />
{shortName(school.school_name)}
</span>
{children}
</div>
);
}
export type ChipTone = 'good' | 'warn' | 'bad' | 'neutral';
const CHIP_TONE_CLASS: Record<ChipTone, string> = {
good: styles.chipGood,
warn: styles.chipWarn,
bad: styles.chipBad,
neutral: styles.chipNeutral,
};
export function Chip({ tone, children }: { tone: ChipTone; children: ReactNode }) {
return <span className={`${styles.chip} ${CHIP_TONE_CLASS[tone]}`}>{children}</span>;
}
export const sectionStyles = styles;
+9 -41
View File
@@ -1,50 +1,18 @@
/** /**
* Custom hook for managing school comparison state * Custom hook for managing school comparison state.
* Uses shared context for real-time updates across components *
* This hook is mounted on every page via the global Navigation and
* ComparisonToast, so it must stay cheap it exposes basket state only.
* The compare page fetches `/api/compare` itself (ComparisonView); nothing
* ever read the comparison payload from here, so the previous per-page SWR
* fetch (which fired on every page whenever the basket was non-empty) was
* dead weight and has been removed.
*/ */
'use client'; 'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import { useComparisonContext } from '@/context/ComparisonContext'; import { useComparisonContext } from '@/context/ComparisonContext';
import type { ComparisonResponse } from '@/lib/types';
export function useComparison() { export function useComparison() {
const { return useComparisonContext();
selectedSchools,
addSchool,
removeSchool,
replaceSchools,
clearAll,
isSelected,
canAddMore,
isInitialized,
} = useComparisonContext();
// Fetch comparison data for selected schools
const urns = selectedSchools.map((s) => s.urn).join(',');
const { data, error, isLoading, mutate } = useSWR<ComparisonResponse>(
selectedSchools.length > 0 ? `/compare?urns=${urns}` : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 10000,
}
);
return {
selectedSchools,
comparisonData: data?.comparison,
isLoading,
error,
addSchool,
removeSchool,
replaceSchools,
clearAll,
isSelected,
canAddMore,
isInitialized,
mutate,
};
} }
+23
View File
@@ -0,0 +1,23 @@
/**
* Viewport hook shared by the chart components.
* Hydration-safe: SSR and the first client render report desktop; the
* media-query subscription flips the value after mount.
*/
'use client';
import { useEffect, useState } from 'react';
export function useIsMobile(maxWidth = 640): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${maxWidth}px)`);
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [maxWidth]);
return isMobile;
}

Some files were not shown because too many files have changed in this diff Show More