Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 4.8 ed8c396eb9 feat(detail): move religious character into header details + mobile reveal
Religious character now sits with the other labelled header details (near
County / Constituency) as "Religious character: <denomination>" ("None" for
Does not apply / None), instead of as an identity chip.

On mobile/tablet (≤768px) the header details block was hidden outright; it's
now collapsed behind a "Show all details" toggle that reveals the full block
(headteacher, website, pupils, trust, phone, religious character, county,
constituency). Applied to both the primary and secondary detail views for
parity. Extends the e2e journey with the mobile toggle behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:18:12 +01:00
TudorandClaude Opus 4.8 a102508ef1 fix(data): strip any table alias in missing-column matcher, not just s.
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 17s
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 2m49s
The graceful-degradation fallback keys off the column named in a Postgres
UndefinedColumn error, but the matcher only stripped an `s.` alias. The two
new dim_location columns (county, parliamentary_constituency) are selected
via the `l.` alias and Postgres reports them unquoted as
"column l.county does not exist" — which the old regex failed to match at
all, returning None.

If dim_school is rebuilt (telephone/nursery present) but dim_location is not
yet (county/parliamentary_constituency missing) — plausible since they are
independently-rebuilt dbt models — the fallback branch never matched and
load_school_data_as_dataframe() returned an empty DataFrame, showing zero
schools sitewide instead of degrading those columns to NULL.

Generalise the alias prefix to `\w+\.` and cover the l.-qualified case in
tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:58:27 +01:00
TudorandClaude Opus 4.8 684da611ff feat(detail): always show religious character, "None" for no religious character
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 22s
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 2m56s
Previously the chip was hidden for "Does not apply"/"None". Show it in all
cases, rendering "None" for those two sentinel values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:24:27 +01:00
TudorandClaude Opus 4.8 0186227ced feat(detail): surface GIAS identity/contact details, drop unwired sections
PR Checks / Frontend Typecheck + Tests (pull_request) Canceled after 58s
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
Add seven school-identity fields to the detail header (both primary and
secondary views): age range, religious character, nursery and sixth-form
indicators as chips; telephone (tel: link), county and parliamentary
constituency as header details. religious_denomination, age_range and
has_sixth_form were already served; telephone, nursery_provision, county
and parliamentary_constituency are newly wired through the marts query
(with a NULL fallback for un-rebuilt marts, mirroring has_sixth_form) and
the school_info API response.

Remove three UI sections the backend never populated (always null): Year 1
Phonics, the SEN "types of additional needs" breakdown, and the average
class-size card — along with their now-dead props, route plumbing, and the
SenDetail/Phonics types + class_size_avg field.

Extend the e2e detail journey to assert the Phonics section is gone and the
new header fields render when the record carries them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:23:08 +01:00
tudor d02a323cdc Merge pull request 'fix(detail): give Results Over Time chart more height on desktop' (#78) from fix/detail-trend-chart-taller 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 58s
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 1m13s
Reviewed-on: #78
2026-07-21 21:08:34 +00:00
TudorandClaude Opus 4.8 32f8a02862 fix(detail): give Results Over Time chart more height on desktop
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 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
The trend chart was capped at 280px tall on desktop, squishing the
lines together and making them hard to read. Raise the base
.chartContainer height to 380px in both the primary and secondary
detail views.

Mobile is unaffected: the base value is already overridden to
height:auto by the existing max-width:768px query, where
PerformanceChart's own .chartWrapper carries the canvas height.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 22:05:10 +01:00
tudor 7e3096c881 Merge pull request 'feat(analytics): load Umami heatmap recorder' (#77) from feat/umami-heatmap-recorder 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 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 47s
Reviewed-on: #77
2026-07-21 15:06:04 +00:00
TudorandClaude Opus 4.8 1f80571b1f feat(analytics): load Umami heatmap recorder
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) Failing after 42s
Adds the Umami session recorder (recorder.js) that powers heatmaps,
alongside the existing analytics tracker. Loaded via next/script with
the same data-domains guard as the main tracker so it only fires on the
production hostnames — staging runs the same image under a different
host and must not feed the heatmap.

Cookieless: recorder.js uses Umami's server-side session model (no
cookie or local storage on the device), so it adds no new PECR
consent trigger.

Verified with tsc --noEmit and next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 16:03:18 +01:00
tudor 3132f07af2 Merge pull request 'fix(detail): compare each SATs bar to its own national benchmark' (#76) from fix/sats-per-level-national 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 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #76
2026-07-21 13:56:55 +00:00
TudorandClaude Opus 4.8 8a9ba30cc2 fix(detail): compare each SATs bar to its own national benchmark
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The KS2 SATs chart drew a single national-average line spanning the
full height of each subject's chart area, positioned at the national
*expected* value. But the area stacks two bars — Expected and Exceeding
— and the higher-standard/greater-depth national is a very different,
much lower figure (e.g. reading higher standard ~29% vs expected ~75%).
So the line crossed the Exceeding bar at the wrong place, making every
school's exceeding result look far below national when it wasn't.

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

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

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

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

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

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

Verified with tsc --noEmit and next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 12:22:49 +01:00
tudor 5981916b82 Merge pull request 'fix(rankings,admissions): correct education-accuracy audit findings' (#74) from fix/rankings-admissions-accuracy into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #74
2026-07-21 11:16:14 +00:00
TudorandClaude Opus 4.8 a5b2296240 fix(rankings,admissions): correct accuracy issues from education audit
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m11s
Follow-up audit of the rankings and admissions pages.

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

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

Added an admissions smoke journey (static content, stable milestones).
Verified with tsc --noEmit and next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 12:08:50 +01:00
tudor af23fbf27e Merge pull request 'fix(home): correct education/Ofsted claims on the landing page' (#73) from fix/homepage-education-accuracy into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 43s
Reviewed-on: #73
2026-07-21 09:04:57 +00:00
TudorandClaude Opus 4.8 22e9eb2d48 fix(home): make Att8 bar match its numbers and separate safeguarding
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m6s
Two internal-consistency fixes on the "What you'll see" mock:

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

Verified with tsc --noEmit and next build.

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

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

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

Verified with tsc --noEmit and next build.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 22:49:11 +01:00
tudor e4c8b86fe7 Merge pull request 'fix(detail): align Ofsted Report Card grade chips' (#71) from fix/report-card-chip-alignment into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #71
2026-07-20 21:32:45 +00:00
TudorandClaude Opus 4.8 1c1df77961 fix(detail): align Ofsted Report Card grade chips
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 8s
The grade boxes rendered inconsistently: Safeguarding "Met" was a small
0.8125rem inline pill while grade values ("Strong") were 1.25rem full-width
chips, and because category labels wrap to one or two lines each value sat at
a different vertical position. Two-line values like "Expected standard" also
produced a taller box.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-20 22:31:58 +01:00
tudor 993822d769 Merge pull request 'fix: don’t portray special schools as failing the mainstream benchmark' (#70) from fix/special-schools-not-failing into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 7s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m40s
Reviewed-on: #70
2026-07-20 21:14:57 +00:00
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
21 changed files with 719 additions and 199 deletions
+8
View File
@@ -606,17 +606,21 @@ async def get_school_details(request: Request, urn: int):
"religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""),
"has_sixth_form": latest.get("has_sixth_form"),
"nursery_provision": latest.get("nursery_provision"),
"status": latest.get("status"),
"latitude": latest.get("latitude"),
"longitude": latest.get("longitude"),
"phase": latest.get("phase"),
# GIAS fields
"website": latest.get("website"),
"telephone": latest.get("telephone"),
"headteacher_name": latest.get("headteacher_name"),
"capacity": latest.get("capacity"),
"total_pupils": latest.get("gias_total_pupils"),
"trust_name": latest.get("trust_name"),
"gender": latest.get("gender"),
"county": latest.get("county"),
"parliamentary_constituency": latest.get("parliamentary_constituency"),
}.items()
}
@@ -807,6 +811,10 @@ async def get_la_averages(request: Request):
_KS2_NATIONAL_METRICS = [
"rwm_expected_pct", "rwm_high_pct",
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
# Per-subject higher-standard nationals: reading/maths reach the "higher
# standard" in the tests; writing is teacher-assessed at "greater depth"
# (writing_gd_pct). Needed so each SATs bar compares to its own benchmark.
"reading_high_pct", "writing_gd_pct", "maths_high_pct",
"gps_expected_pct", "gps_high_pct", "science_expected_pct",
"reading_avg_score", "maths_avg_score", "gps_avg_score",
"reading_progress", "writing_progress", "maths_progress",
+47 -1
View File
@@ -169,6 +169,8 @@ _MAIN_QUERY = text("""
s.total_pupils AS gias_total_pupils,
s.headteacher_name,
s.website,
s.telephone,
s.nursery_provision,
foi.ofsted_grade,
foi.ofsted_date,
foi.ofsted_framework,
@@ -178,6 +180,8 @@ _MAIN_QUERY = text("""
l.address_line1 AS address1,
l.address_line2 AS address2,
l.town,
l.county,
l.parliamentary_constituency,
l.postcode,
l.latitude,
l.longitude,
@@ -301,6 +305,32 @@ for _old, _new in _LEGACY_NAME_REPLACEMENTS:
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
# Fallback used when marts predate the optional GIAS/location detail columns
# (telephone, nursery provision, county, parliamentary constituency) — i.e. the
# nightly dbt pipeline hasn't rebuilt the mart yet on this DB. Keeps each column
# present as NULL so the whole data load degrades gracefully instead of failing.
_EXTRA_OPTIONAL_COLUMNS = (
"telephone",
"nursery_provision",
"county",
"parliamentary_constituency",
)
_MAIN_QUERY_NO_EXTRA_COLS = str(_MAIN_QUERY)
for _col, _tbl in (
("telephone", "s"),
("nursery_provision", "s"),
("county", "l"),
("parliamentary_constituency", "l"),
):
_src = f"{_tbl}.{_col},"
assert _src in _MAIN_QUERY_NO_EXTRA_COLS, (
f"expected {_src!r} to be present in _MAIN_QUERY before replacement"
)
_MAIN_QUERY_NO_EXTRA_COLS = _MAIN_QUERY_NO_EXTRA_COLS.replace(
_src, f"NULL AS {_col},"
)
_MAIN_QUERY_NO_EXTRA_COLS = text(_MAIN_QUERY_NO_EXTRA_COLS)
_GIAS_CODE_COLUMN_NAMES = (
"phase_code",
"school_type_code",
@@ -309,7 +339,11 @@ _GIAS_CODE_COLUMN_NAMES = (
"admissions_policy_code",
)
_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist')
# Strip any single table alias prefix (s., l., p., foi., …) — Postgres reports
# an undefined *qualified* column unquoted as "column l.county does not exist",
# so matching only the s. alias would miss dim_location columns (county,
# parliamentary_constituency) and defeat the graceful-degradation fallback.
_MISSING_COLUMN_RE = re.compile(r'column "?(?:\w+\.)?(\w+)"? does not exist')
def _missing_column_name(exc: Exception) -> Optional[str]:
@@ -352,6 +386,18 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
except Exception as exc2:
print(f"Warning: Could not load school data from marts: {exc2}")
return pd.DataFrame()
elif missing in _EXTRA_OPTIONAL_COLUMNS:
logging.getLogger(__name__).warning(
"marts.dim_school/dim_location is missing %s (pipeline hasn't "
"rebuilt the mart yet on this DB) — retrying without the "
"optional detail columns: %s",
missing, exc,
)
try:
df = pd.read_sql(_MAIN_QUERY_NO_EXTRA_COLS, 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()
+18
View File
@@ -73,6 +73,24 @@ def test_missing_column_name_table_prefixed():
)
def test_missing_column_name_location_alias_prefixed():
# dim_location columns are selected via the `l.` alias; Postgres reports a
# missing qualified column unquoted (e.g. "column l.county does not exist").
# The matcher must strip any alias, not just `s.`, or the county /
# parliamentary_constituency fallback never triggers and the whole data
# load degrades to an empty DataFrame (zero schools) instead of NULLs.
assert (
_missing_column_name(_fake_exc("column l.county does not exist"))
== "county"
)
assert (
_missing_column_name(
_fake_exc("column l.parliamentary_constituency does not exist")
)
== "parliamentary_constituency"
)
def test_missing_column_name_no_match_returns_none():
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
@@ -25,6 +25,11 @@ class _Ks2Row:
year = LATEST
rwm_expected_pct = 62.1
gps_expected_pct = 72.0
# Per-subject higher-standard nationals — reading/maths reach the higher
# standard, writing is teacher-assessed at greater depth (writing_gd_pct).
reading_high_pct = 29.0
writing_gd_pct = 13.0
maths_high_pct = 24.0
class _Ks4Row:
@@ -84,6 +89,16 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
def test_per_subject_higher_standard_nationals_are_surfaced(payload):
# The SATs chart compares each bar to its own benchmark, so the per-subject
# higher-standard / greater-depth nationals must reach the payload — not
# only the combined rwm_high_pct.
body = payload(_StubSession)
assert body["primary"]["reading_high_pct"] == 29.0
assert body["primary"]["writing_gd_pct"] == 13.0
assert body["primary"]["maths_high_pct"] == 24.0
def test_ks4_secondary_empty_when_mart_missing(payload):
# No computed stand-in: the UI labels national figures as official DfE
# data, so an empty mart must yield an empty secondary series.
+57
View File
@@ -121,6 +121,51 @@ test('school detail page renders name and performance data', async ({ page }) =>
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
});
test('school detail page shows GIAS identity/contact details and drops the unwired Phonics section', async ({ page }) => {
const [urn] = await twoPrimaryUrns(page);
const res = await page.request.get(`/api/schools/${urn}`);
expect(res.ok()).toBeTruthy();
const info = (await res.json()).school_info;
await page.goto(`/school/${urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
// Phonics, SEN-type breakdown and average class size were never populated by
// the backend — the sections have been removed, so the Phonics section (its
// own #phonics anchor) must no longer exist.
await expect(page.locator('#phonics')).toHaveCount(0);
// Newly surfaced GIAS/location fields render when the record carries them.
const ageMatch = String(info.age_range ?? '').match(/^\s*(\d+)\s*[-]\s*(\d+)\s*$/);
if (ageMatch) {
await expect(page.getByText(`Ages ${ageMatch[1]}${ageMatch[2]}`).first()).toBeVisible();
}
if (info.telephone) {
await expect(page.locator('a[href^="tel:"]').first()).toBeVisible();
}
if (info.parliamentary_constituency) {
await expect(page.getByText('Constituency:').first()).toBeVisible();
}
});
test('header details collapse behind a "Show all details" toggle on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const [urn] = await twoPrimaryUrns(page);
await page.goto(`/school/${urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
// Collapsed by default on mobile/tablet …
const details = page.locator('#school-header-details');
await expect(details).toBeHidden();
const toggle = page.getByRole('button', { name: /show all details/i });
await expect(toggle).toBeVisible();
// … and the link reveals them (label flips to "Hide details").
await toggle.click();
await expect(details).toBeVisible();
await expect(page.getByRole('button', { name: /hide details/i })).toBeVisible();
});
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
@@ -438,6 +483,18 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
});
test('admissions guide renders its key milestones', async ({ page }) => {
// Static content page — assert the guide loads and the load-bearing
// milestones parents rely on are present (dates are statutory, so these
// strings are stable invariants, not data-refresh-sensitive).
await page.goto('/admissions');
await expect(page.getByRole('heading', { name: /School Admissions Guide/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /Primary school admissions/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /Secondary school admissions/i })).toBeVisible();
// National Offer Day is the milestone the whole guide builds toward.
await expect(page.getByText(/National Offer Day/i).first()).toBeVisible();
});
test('rankings page loads a populated table', async ({ page }) => {
await page.goto('/rankings');
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
+8
View File
@@ -85,6 +85,14 @@ export default function RootLayout({
data-performance="true"
strategy="afterInteractive"
/>
{/* Heatmap / session recorder — same data-domains guard so staging
(same image, different host) never feeds the heatmap. */}
<Script
src="https://analytics.schoolcompare.co.uk/recorder.js"
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
strategy="afterInteractive"
/>
</head>
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
<div className="noise-overlay" />
+1 -5
View File
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -191,8 +191,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
@@ -205,8 +203,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
@@ -442,6 +442,58 @@
line-height: 1;
}
/* ─── Easy-to-miss callouts ──────────────────────────── */
.calloutGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.7rem;
}
.callout {
display: flex;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
border: 1px solid rgba(201, 162, 39, 0.28);
border-left: 3px solid var(--accent-gold, #c9a227);
border-radius: 8px;
}
.calloutIcon {
flex-shrink: 0;
width: 1.05rem;
height: 1.05rem;
margin-top: 0.1rem;
border-radius: 50%;
background: var(--accent-gold, #c9a227);
color: #fff;
font-size: 0.72rem;
font-weight: 800;
line-height: 1.05rem;
text-align: center;
}
.calloutText {
min-width: 0;
}
.calloutTitle {
font-size: 0.82rem;
font-weight: 700;
color: var(--accent-gold-text, #7a6800);
line-height: 1.3;
margin-bottom: 0.15rem;
}
.calloutBody {
font-size: 0.82rem;
color: var(--text-secondary, #5c564d);
line-height: 1.5;
margin: 0;
}
/* ─── Tips ───────────────────────────────────────────── */
.tips {
+48 -3
View File
@@ -54,6 +54,9 @@ interface Step {
title: string;
body: string;
highlight?: 'deadline' | 'offer';
/* Easy-to-miss, deadline-sensitive warnings surfaced as callout boxes
rather than buried in the body prose. */
callouts?: { title: string; body: string }[];
/* How SchoolCompare helps at this stage of the journey */
tool?: { label: string; href: string };
}
@@ -62,6 +65,16 @@ const SECONDARY_STEPS: Step[] = [
{
title: 'Check entry criteria',
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
callouts: [
{
title: 'Selective schools register earlier',
body: 'Grammar and other selective schools need separate entrance-test registration months earlier — often by September, well before the 31 October deadline.',
},
{
title: 'Faith schools may need an extra form',
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, on top of the council application.',
},
],
tool: { label: 'Find schools & view their admissions history', href: '/' },
},
{
@@ -73,14 +86,14 @@ const SECONDARY_STEPS: Step[] = [
{
date: '31 October',
title: 'Application deadline',
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally list schools in the genuine order you want them, not strategically.',
body: 'Submit your ranked list of schools — most councils allow three to six preferences (London boroughs typically six). Councils treat all preferences equally, so list schools in the genuine order you want them, not strategically.',
highlight: 'deadline',
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
},
{
date: '1 March',
title: 'National Offer Day',
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
body: 'Your allocated school appears on the council\'s online admissions portal and is sent by email. Release times are set by each council — often from late afternoon, though some publish overnight — so check your council\'s page rather than refreshing at midnight.',
highlight: 'offer',
tool: { label: 'Look up your allocated school', href: '/' },
},
@@ -101,6 +114,12 @@ const PRIMARY_STEPS: Step[] = [
{
title: 'Research entry criteria',
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
callouts: [
{
title: 'Faith schools may need an extra form',
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, in addition to the council application.',
},
],
tool: { label: 'Find schools & view their admissions history', href: '/' },
},
{
@@ -144,7 +163,7 @@ interface Tip {
const TIPS: Tip[] = [
{
heading: 'Equal preference rule',
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually want them.',
body: 'Councils consider every school you list before making any offer, then give you the highest-ranked one you qualify for. Your order only decides which qualifying school you get — you can\'t game it, so list schools in the order you genuinely want them.',
},
{
heading: 'Late applications go to the back',
@@ -307,6 +326,19 @@ export function AdmissionsView() {
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p>
{step.callouts && step.callouts.length > 0 && (
<div className={styles.calloutGroup}>
{step.callouts.map((c) => (
<div key={c.title} className={styles.callout}>
<span className={styles.calloutIcon} aria-hidden="true">!</span>
<div className={styles.calloutText}>
<div className={styles.calloutTitle}>{c.title}</div>
<p className={styles.calloutBody}>{c.body}</p>
</div>
</div>
))}
</div>
)}
{step.tool && (
<a
href={step.tool.href}
@@ -355,6 +387,19 @@ export function AdmissionsView() {
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p>
{step.callouts && step.callouts.length > 0 && (
<div className={styles.calloutGroup}>
{step.callouts.map((c) => (
<div key={c.title} className={styles.callout}>
<span className={styles.calloutIcon} aria-hidden="true">!</span>
<div className={styles.calloutText}>
<div className={styles.calloutTitle}>{c.title}</div>
<p className={styles.calloutBody}>{c.body}</p>
</div>
</div>
))}
</div>
)}
{step.tool && (
<a
href={step.tool.href}
+5 -5
View File
@@ -13,17 +13,17 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
<div className={styles.editorialGrid}>
<div className={styles.editorialText}>
<div className={styles.editorialKicker}>About school data</div>
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
<h2 className={styles.editorialHeading}>Making England&apos;s school performance data actually readable</h2>
<p>
School performance data in England is rich but fragmented. The Department for Education publishes
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
demographics each in its own table, each with its own jargon.
School performance data in England is rich but fragmented. The Department for Education and Ofsted
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
and demographics each in its own table, each with its own jargon.
</p>
<p>
SchoolCompare brings it all into one place. Every school page shows performance against the national
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
parents, governors, journalists, and anyone who wants to understand a school without reading a
40-page inspection report.
full inspection report.
</p>
</div>
<div className={styles.factbox}>
+66 -14
View File
@@ -720,10 +720,13 @@
.miniRowHead {
display: flex;
justify-content: space-between;
font-size: 0.48rem;
align-items: baseline;
gap: 0.2rem;
font-size: 0.44rem;
color: var(--text-muted, #6d685f);
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.01em;
white-space: nowrap;
}
.miniRowHead strong {
@@ -869,28 +872,77 @@
color: var(--text-primary, #1a1612);
}
.ofstedBadge {
.ofstedFramework {
align-self: flex-start;
padding: 0.2rem 0.55rem;
padding: 0.15rem 0.5rem;
border-radius: 4px;
background: rgba(45, 125, 125, 0.12);
color: var(--accent-teal, #2d7d7d);
font-size: 0.55rem;
background: rgba(90, 58, 110, 0.12);
color: #5a3a6e;
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.ofstedVerdict {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-size: 1.05rem;
.rcList {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.rcRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.rcArea {
font-size: 0.62rem;
color: var(--text-secondary, #4a453d);
}
.rcChip {
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.52rem;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary, #1a1612);
letter-spacing: 0.03em;
white-space: nowrap;
flex-shrink: 0;
}
.ofstedVerdict em {
.rcChipGood {
background: rgba(45, 125, 125, 0.12);
color: var(--accent-teal, #2d7d7d);
font-style: normal;
}
.rcChipNeutral {
background: var(--accent-gold-bg, rgba(180, 150, 40, 0.12));
color: var(--accent-gold-text, #7a6800);
}
/* Safeguarding is a separate binary met/not-met judgement, not a graded area,
so it sits below the five-point list behind a divider with its own chip. */
.rcSafeguarding {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.05rem;
padding-top: 0.35rem;
border-top: 1px dashed var(--border-color, #e5dfd5);
}
.rcSep {
color: var(--text-muted, #8a847a);
font-style: italic;
}
.rcChipMet {
background: transparent;
color: var(--accent-teal, #2d7d7d);
border: 1px solid rgba(45, 125, 125, 0.4);
}
.ofstedMeta {
+1 -1
View File
@@ -261,7 +261,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
<div className={styles.heroSection}>
<span className={styles.heroEyebrow}>
<span className={styles.heroEyebrowDot} aria-hidden="true" />
Updated with 2026/2027 admissions results
Updated for the 2026/27 admissions round
</span>
<h1 className={styles.heroTitle}>
Every school in England, <em className={styles.heroEmph}>compared.</em>
+37 -15
View File
@@ -5,15 +5,26 @@
import styles from './HomeView.module.css';
export function HowItWorksSection() {
// "exc" is the school's above-expected result. DfE names this differently
// by subject: "higher standard" for the reading and maths tests, "greater
// depth" for teacher-assessed writing. "nat" is the national expected %.
const miniCascade = [
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
{ subj: 'Reading', exp: 96, exc: 44, nat: 75, excLabel: 'Higher std' },
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
];
const compareRows = [
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
];
// Report card (Ofsted's framework from Nov 2025): one rating per area of
// school life on a five-point scale. Safeguarding is judged separately, as a
// binary met/not-met outcome — kept out of this list and shown on its own.
const reportCard: Array<{ area: string; grade: string; good: boolean }> = [
{ area: 'Achievement', grade: 'Strong', good: true },
{ area: 'Curriculum & teaching', grade: 'Expected standard', good: false },
{ area: 'Attendance & behaviour', grade: 'Strong', good: true },
];
return (
@@ -29,7 +40,7 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseBlock}>
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
<div className={styles.miniCascade}>
{miniCascade.map(({ subj, exp, exc, nat }) => (
{miniCascade.map(({ subj, exp, exc, nat, excLabel }) => (
<div key={subj} className={styles.miniCascadeCol}>
<div className={styles.miniSubj}>{subj}</div>
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
@@ -37,7 +48,7 @@ export function HowItWorksSection() {
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
</div>
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
<div className={styles.miniRowHead}><span>{excLabel}</span><strong>{exc}%</strong></div>
<div className={styles.miniTrack}>
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
</div>
@@ -49,15 +60,15 @@ export function HowItWorksSection() {
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
<div className={styles.att8Row}>
<div className={styles.att8BarWrap}>
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
<div className={styles.att8BarHead}><span>This school</span><span>National avg 45.9</span></div>
<div className={styles.att8Track}>
<div className={styles.att8Fill} style={{ width: '62%' }} />
<div className={styles.att8NatLine} style={{ left: '50%' }} />
<div className={styles.att8Fill} style={{ width: '54.8%' }} />
<div className={styles.att8NatLine} style={{ left: '45.9%' }} />
</div>
</div>
<div className={styles.att8Score}>
<div className={styles.att8Value}>62.4</div>
<div className={styles.att8Delta}>+12.2 vs national</div>
<div className={styles.att8Value}>54.8</div>
<div className={styles.att8Delta}>+8.9 vs national</div>
</div>
</div>
</div>
@@ -77,15 +88,26 @@ export function HowItWorksSection() {
<span className={styles.ofstedBullet} />
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
</div>
<span className={styles.ofstedBadge}>OUTSTANDING</span>
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
<span className={styles.ofstedFramework}>Report card · five-point scale</span>
<div className={styles.rcList}>
{reportCard.map(({ area, grade, good }) => (
<div key={area} className={styles.rcRow}>
<span className={styles.rcArea}>{area}</span>
<span className={`${styles.rcChip} ${good ? styles.rcChipGood : styles.rcChipNeutral}`}>{grade}</span>
</div>
))}
</div>
<div className={styles.rcSafeguarding}>
<span className={styles.rcArea}>Safeguarding <span className={styles.rcSep}>· separate check</span></span>
<span className={`${styles.rcChip} ${styles.rcChipMet}`}>Met</span>
</div>
<div className={styles.ofstedMeta}>Full inspection · March 2026</div>
</div>
</div>
<div className={styles.hiwCardBody}>
<div className={styles.hiwStep}>Judgement</div>
<div className={styles.hiwTitle}>Ofsted at a glance</div>
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline without opening a 40-page report.</p>
<p className={styles.hiwDesc}>Since November 2025, Ofsted rates each area of school life on a five-point scale rather than one overall word. We show every area and the inspection date and, for schools not yet reinspected, the older overall grade they still carry.</p>
</div>
</div>
+10 -3
View File
@@ -118,7 +118,7 @@ export function RankingsView({
<header className={styles.header}>
<h1>School Rankings</h1>
<p className={styles.subtitle}>
Top-performing schools by {metricLabel.toLowerCase()}
Schools ranked by {metricLabel.toLowerCase()}
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> showing top {rankings.length}</span>}
</p>
</header>
@@ -143,7 +143,10 @@ export function RankingsView({
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
{isProgressScore && (
<p className={styles.progressHint}>Progress scores: 0 = national average. Positive = above average.</p>
<p className={styles.progressHint}>
Progress scores: 0 = national average. Positive = above average.
{isPrimary && ' KS2 progress isnt published for 2023/24 or 2024/25 (theres no key stage 1 baseline) — pick an earlier year to rank by it.'}
</p>
)}
{/* Filters */}
@@ -218,7 +221,11 @@ export function RankingsView({
{rankings.length === 0 ? (
<EmptyState
title="No rankings found"
message="Try selecting a different metric, area, or year."
message={
isPrimary && isProgressScore
? 'KS2 progress scores arent published for the most recent years (2023/24 and 2024/25) because theres no key stage 1 baseline. Select an earlier year to see progress rankings.'
: 'Try selecting a different metric, area, or year.'
}
action={{
label: 'Clear filters',
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
+31 -15
View File
@@ -44,30 +44,46 @@
background: var(--bg-secondary, #f3ede4);
}
/* ── National average marker ── */
.natLine {
/* ── Per-bar national average marker ──
Each bar compares against its own benchmark (expected vs higher standard /
greater depth), so the marker sits on the individual bar's track rather than
as one line spanning both bars. */
.natTick {
position: absolute;
top: 0;
height: calc(100% - 20px);
width: 1.5px;
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */
z-index: 2;
top: -3px;
bottom: -3px;
width: 2px;
transform: translateX(-50%);
background: var(--accent-coral, #e07256);
border-radius: 2px;
z-index: 4;
pointer-events: none;
}
.natPill {
.natTick::before {
content: '';
position: absolute;
top: -10px;
top: -3px;
left: 50%;
transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-coral, #e07256);
color: #fff;
}
.barHeaderRight {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.natLabel {
font-size: 0.55rem;
font-weight: 700;
padding: 0.1rem 0.35rem;
border-radius: 4px;
white-space: nowrap;
z-index: 3;
letter-spacing: 0.02em;
color: var(--accent-coral-dark, #b04a2e);
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* ── Bar rows ── */
+54 -36
View File
@@ -8,6 +8,9 @@ interface SubjectData {
expectedPct: number | null;
exceedingPct: number | null;
nationalExpectedPct: number | null;
/** National higher-standard / greater-depth benchmark for the exceeding
* bar — a different figure from the expected national. */
nationalExceedingPct: number | null;
}
interface SatsChartProps {
@@ -17,11 +20,45 @@ interface SatsChartProps {
const RULER_TICKS = [0, 25, 50, 75, 100];
const GRIDLINE_POSITIONS = [25, 50, 75];
/** One results bar with a national marker positioned on its own track, so the
* expected and exceeding bars each compare against their own benchmark. */
function BarRow({
label,
value,
national,
barRef,
barClass,
}: {
label: string;
value: number;
national: number | null;
barRef: React.RefObject<HTMLDivElement | null>;
barClass: string;
}) {
return (
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>{label}</span>
<span className={styles.barHeaderRight}>
{national != null && <span className={styles.natLabel}>nat {national.toFixed(0)}%</span>}
<span className={styles.barLabel}>{value.toFixed(0)}%</span>
</span>
</div>
<div className={styles.barTrack}>
<div ref={barRef} className={`${styles.bar} ${barClass}`} data-width={value} />
{national != null && (
<div className={styles.natTick} style={{ left: `${national}%` }} aria-hidden="true" />
)}
</div>
</div>
);
}
function SubjectColumn({ subject }: { subject: SubjectData }) {
const expectedRef = useRef<HTMLDivElement>(null);
const exceedingRef = useRef<HTMLDivElement>(null);
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
// Animate bars on mount
useEffect(() => {
@@ -51,44 +88,25 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
))}
</div>
{/* National average marker */}
{nationalExpectedPct != null && (
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
</div>
)}
{/* Bars */}
{/* Bars — each carries its own national marker */}
<div className={styles.barGroup}>
{expectedPct != null && (
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>Expected</span>
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span>
</div>
<div className={styles.barTrack}>
<div
ref={expectedRef}
className={`${styles.bar} ${styles.barExpected}`}
data-width={expectedPct}
/>
</div>
</div>
<BarRow
label="Expected"
value={expectedPct}
national={nationalExpectedPct}
barRef={expectedRef}
barClass={styles.barExpected}
/>
)}
{exceedingPct != null && (
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>Exceeding</span>
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span>
</div>
<div className={styles.barTrack}>
<div
ref={exceedingRef}
className={`${styles.bar} ${styles.barExceeding}`}
data-width={exceedingPct}
/>
</div>
</div>
<BarRow
label="Exceeding"
value={exceedingPct}
national={nationalExceedingPct}
barRef={exceedingRef}
barClass={styles.barExceeding}
/>
)}
</div>
@@ -135,7 +153,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
Exceeding / high score
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
National average
</div>
</div>
@@ -197,6 +197,22 @@
text-decoration: underline;
}
/* "Show all details" reveal — only rendered on mobile/tablet, where the
header details block is collapsed below the fold. Hidden on desktop. */
.detailsToggle {
display: none;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
padding: 0;
background: none;
border: none;
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent-teal, #2d7d7d);
cursor: pointer;
}
/* Gender split card — sits in the Pupils & Inclusion heroStatGrid */
.genderSplitValue {
display: flex;
@@ -855,7 +871,10 @@
/* Charts Section */
.chartContainer {
width: 100%;
height: 280px;
/* Taller on desktop so the trend lines have vertical room to separate
and read clearly. Mobile overrides this to height:auto below (the
max-width:768px query), so this only affects desktop. */
height: 380px;
position: relative;
}
@@ -1040,6 +1059,41 @@
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 {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
@@ -1167,14 +1221,24 @@
gap: 0.375rem;
}
/* Secondary header info (headteacher, website, pupil count, trust)
isn't needed above the fold on phones — pupil count lives in the
Pupils & Inclusion section, website is one scroll away. Reclaim
the ~3 vertical lines so the actual metrics surface sooner. */
/* Secondary header info (headteacher, website, pupil count, trust,
contact, area) isn't needed above the fold on phones/tablets, so it's
collapsed by default and revealed on demand via the "Show all details"
link — reclaiming the vertical space so the metrics surface sooner. */
.detailsToggle {
display: inline-flex;
}
.headerDetails {
display: none;
}
.headerDetailsOpen {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.metricsGrid {
grid-template-columns: repeat(2, 1fr);
}
+72 -68
View File
@@ -14,11 +14,11 @@ import { MetricTooltip } from './MetricTooltip';
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics,
SchoolAdmissions,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import {
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
} from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
@@ -68,15 +68,13 @@ interface SchoolDetailViewProps {
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
senDetail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
export function SchoolDetailView({
schoolInfo, yearlyData, absenceData,
ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
ofsted, census, admissions, admissionsHistory, deprivation, finance,
}: SchoolDetailViewProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
@@ -101,6 +99,9 @@ export function SchoolDetailView({
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
// "All ▾" jump menu listing every section.
const [sectionsOpen, setSectionsOpen] = useState(false);
// Header details (headteacher, contact, trust, area) collapse behind a
// "Show all details" link on mobile/tablet, where they're below the fold.
const [detailsOpen, setDetailsOpen] = useState(false);
// Back returns to wherever the user came from; deep-links (no in-app history)
// fall back to search so the button never dead-ends or leaves the site.
@@ -161,14 +162,12 @@ export function SchoolDetailView({
// 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,
// have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs,
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
const phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through';
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
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)
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
@@ -220,11 +219,9 @@ export function SchoolDetailView({
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|| (latestResults?.eal_pct != null)
|| (latestResults?.sen_support_pct != null)
|| senDetail != null
|| hasGenderSplit;
const hasSchoolLife = absenceData != null || census?.class_size_avg != null;
const hasPhonics = phonics != null && phonics.year1_phonics_pct != null;
const hasSchoolLife = absenceData != null;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
@@ -265,7 +262,6 @@ export function SchoolDetailView({
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' });
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -358,6 +354,15 @@ export function SchoolDetailView({
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)}
{schoolInfo.age_range && (
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
<span className={styles.metaItem}>Nursery</span>
)}
{schoolInfo.has_sixth_form && (
<span className={styles.metaItem}>Sixth form</span>
)}
</div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
@@ -382,7 +387,20 @@ export function SchoolDetailView({
)}
</p>
)}
<div className={styles.headerDetails}>
<button
type="button"
className={styles.detailsToggle}
aria-expanded={detailsOpen}
aria-controls="school-header-details"
onClick={() => setDetailsOpen((o) => !o)}
>
{detailsOpen ? 'Hide details' : 'Show all details'}
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
</button>
<div
id="school-header-details"
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
@@ -416,6 +434,32 @@ export function SchoolDetailView({
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
{schoolInfo.telephone && (
<span className={styles.headerDetail}>
<strong>Phone:</strong>{' '}
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
{schoolInfo.telephone}
</a>
</span>
)}
{schoolInfo.religious_denomination && (
<span className={styles.headerDetail}>
<strong>Religious character:</strong>{' '}
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
? 'None'
: schoolInfo.religious_denomination}
</span>
)}
{schoolInfo.county && (
<span className={styles.headerDetail}>
<strong>County:</strong> {schoolInfo.county}
</span>
)}
{schoolInfo.parliamentary_constituency && (
<span className={styles.headerDetail}>
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
</span>
)}
</div>
</div>
<div className={styles.actions} ref={heroActionsRef}>
@@ -577,7 +621,7 @@ export function SchoolDetailView({
<p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p>
<div className={styles.metricsGrid}>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
@@ -623,7 +667,7 @@ export function SchoolDetailView({
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={styles.metricsGrid}>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
@@ -752,18 +796,22 @@ export function SchoolDetailView({
expectedPct: latestResults.reading_expected_pct,
exceedingPct: latestResults.reading_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
},
{
name: 'Writing',
expectedPct: latestResults.writing_expected_pct,
exceedingPct: latestResults.writing_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
// Writing's higher level is teacher-assessed "greater depth".
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
},
{
name: 'Maths',
expectedPct: latestResults.maths_expected_pct,
exceedingPct: latestResults.maths_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
},
]}
/>
@@ -925,6 +973,15 @@ export function SchoolDetailView({
</div>
)}
</div>
{/* All-through admissions data covers a single entry point (usually the
Year 7 secondary intake), not reception — say so, or a parent could
read these as the whole-school figures. */}
{isAllThrough && admissions.school_phase && (
<p className={styles.sectionSubtitle}>
These figures are for {admissions.school_phase.toLowerCase()} entry
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
</p>
)}
<div className={styles.admissionsViewport}>
{/* This-year Q&A */}
@@ -1071,29 +1128,6 @@ export function SchoolDetailView({
);
})()}
</div>
{senDetail && (
<>
<h3 className={styles.subSectionTitle}>Types of additional needs supported</h3>
<p className={styles.sectionSubtitle}>
What proportion of pupils with additional needs have each type of support need.
</p>
<div className={styles.metricsGrid}>
{[
{ label: 'Speech & Language', pct: senDetail.primary_need_speech_pct },
{ label: 'Autism (ASD)', pct: senDetail.primary_need_autism_pct },
{ label: 'Learning Difficulties', pct: senDetail.primary_need_mld_pct },
{ label: 'Specific Learning (e.g. Dyslexia)', pct: senDetail.primary_need_spld_pct },
{ label: 'Social, Emotional & Mental Health', pct: senDetail.primary_need_semh_pct },
{ label: 'Physical / Sensory', pct: senDetail.primary_need_physical_pct },
].filter(n => n.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={styles.metricValue}>{pct}%</div>
</div>
))}
</div>
</>
)}
</section>
)}
@@ -1218,41 +1252,11 @@ export function SchoolDetailView({
)}
</section>
)}
{/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
{hasPhonics && showPrimaryContent && phonics && (
<section id="phonics" className={styles.card}>
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
<p className={styles.sectionSubtitle}>
Phonics is a key early reading skill. Children are tested at the end of Year 1.
</p>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Passed the phonics check</div>
<div className={styles.metricValue}>{formatPercentage(phonics.year1_phonics_pct)}</div>
<div className={styles.metricHint}>Phonics is a key early reading skill tested at end of Year 1</div>
</div>
{phonics.year2_phonics_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 2 pupils who retook and passed</div>
<div className={styles.metricValue}>{formatPercentage(phonics.year2_phonics_pct)}</div>
</div>
)}
</div>
</section>
)}
{/* School Life */}
{hasSchoolLife && (
<section id="school-life" className={styles.card}>
<h2 className={styles.sectionTitle}>School Life</h2>
<div className={styles.metricsGrid}>
{census?.class_size_avg != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Average class size</div>
<div className={styles.metricValue}>{census.class_size_avg.toFixed(1)}</div>
<div className={styles.metricHint}>Average number of pupils per class</div>
</div>
)}
{absenceData?.overall_absence_rate != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
@@ -207,6 +207,22 @@
text-decoration: underline;
}
/* "Show all details" reveal — only rendered on mobile/tablet, where the
header details block is collapsed below the fold. Hidden on desktop. */
.detailsToggle {
display: none;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
padding: 0;
background: none;
border: none;
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent-teal, #2d7d7d);
cursor: pointer;
}
.actions {
display: flex;
gap: 0.5rem;
@@ -490,7 +506,10 @@
/* ── Charts & Map ────────────────────────────────────── */
.chartContainer {
width: 100%;
height: 280px;
/* Taller on desktop so the trend lines have vertical room to separate
and read clearly. Mobile overrides this to height:auto below (the
max-width:768px query), so this only affects desktop. */
height: 380px;
position: relative;
}
@@ -623,6 +642,41 @@
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 {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
@@ -1028,7 +1082,18 @@
padding: 0.1rem 0.375rem;
}
/* Collapsed below the fold on phones/tablets; revealed via "Show all
details" so the metrics surface sooner. */
.detailsToggle {
display: inline-flex;
}
.headerDetails {
display: none;
}
.headerDetailsOpen {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
@@ -20,7 +20,7 @@ const PerformanceChart = dynamic(
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics,
SchoolAdmissions,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
@@ -68,15 +68,13 @@ interface SecondarySchoolDetailViewProps {
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
senDetail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
export function SecondarySchoolDetailView({
schoolInfo, yearlyData,
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
ofsted, census, admissions, deprivation, finance, absenceData,
}: SecondarySchoolDetailViewProps) {
const router = useRouter();
// Hero map — the "View on map" link opens its fullscreen view.
@@ -85,6 +83,8 @@ export function SecondarySchoolDetailView({
const isInComparison = isSelected(schoolInfo.urn);
const [activeSection, setActiveSection] = useState<string>('');
// Header details collapse behind a "Show all details" link on mobile/tablet.
const [detailsOpen, setDetailsOpen] = useState(false);
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
@@ -248,6 +248,9 @@ export function SecondarySchoolDetailView({
{schoolInfo.age_range && (
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{schoolInfo.nursery_provision && (
<span className={styles.badge}>Nursery</span>
)}
{hasSixthForm && (
<span className={styles.badge}>Sixth form</span>
)}
@@ -280,7 +283,20 @@ export function SecondarySchoolDetailView({
)}
</p>
)}
<div className={styles.headerDetails}>
<button
type="button"
className={styles.detailsToggle}
aria-expanded={detailsOpen}
aria-controls="school-header-details"
onClick={() => setDetailsOpen((o) => !o)}
>
{detailsOpen ? 'Hide details' : 'Show all details'}
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
</button>
<div
id="school-header-details"
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
@@ -310,6 +326,32 @@ export function SecondarySchoolDetailView({
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
{schoolInfo.telephone && (
<span className={styles.headerDetail}>
<strong>Phone:</strong>{' '}
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
{schoolInfo.telephone}
</a>
</span>
)}
{schoolInfo.religious_denomination && (
<span className={styles.headerDetail}>
<strong>Religious character:</strong>{' '}
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
? 'None'
: schoolInfo.religious_denomination}
</span>
)}
{schoolInfo.county && (
<span className={styles.headerDetail}>
<strong>County:</strong> {schoolInfo.county}
</span>
)}
{schoolInfo.parliamentary_constituency && (
<span className={styles.headerDetail}>
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
</span>
)}
</div>
</div>
<div className={styles.actions}>
@@ -374,7 +416,7 @@ export function SecondarySchoolDetailView({
<p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p>
<div className={styles.metricsGrid}>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div>
@@ -419,7 +461,7 @@ export function SecondarySchoolDetailView({
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : (
<div className={styles.metricsGrid}>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
@@ -436,7 +478,7 @@ export function SecondarySchoolDetailView({
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={styles.metricsGrid}>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
+4 -19
View File
@@ -18,6 +18,7 @@ export interface School {
religious_denomination: string | null;
age_range: string | null;
has_sixth_form?: boolean | null;
nursery_provision?: boolean | null;
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
// Address
@@ -60,11 +61,14 @@ export interface School {
// GIAS enrichment fields
website?: string | null;
telephone?: string | null;
headteacher_name?: string | null;
capacity?: number | null;
trust_name?: string | null;
gender?: string | null;
admissions_policy?: string | null;
county?: string | null;
parliamentary_constituency?: string | null;
// Ofsted (for list view — summary only)
ofsted_grade?: 1 | 2 | 3 | 4 | null;
@@ -130,7 +134,6 @@ export interface SchoolCensus {
male_pupils: number | null;
fsm_pct: number | null;
eal_pct: number | null;
class_size_avg?: number | null;
ethnicity_white_pct?: number | null;
ethnicity_asian_pct?: number | null;
ethnicity_black_pct?: number | null;
@@ -159,22 +162,6 @@ export interface SchoolAdmissions {
cross_la_offers?: number | null;
}
export interface SenDetail {
year: number;
primary_need_speech_pct: number | null;
primary_need_autism_pct: number | null;
primary_need_mld_pct: number | null;
primary_need_spld_pct: number | null;
primary_need_semh_pct: number | null;
primary_need_physical_pct: number | null;
primary_need_other_pct: number | null;
}
export interface Phonics {
year: number;
year1_phonics_pct: number | null;
year2_phonics_pct: number | null;
}
export interface SchoolDeprivation {
lsoa_code: string | null;
@@ -343,8 +330,6 @@ export interface SchoolDetailsResponse {
admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */
admissions_history: SchoolAdmissions[];
sen_detail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}