Compare commits

..
Author SHA1 Message Date
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
41 changed files with 5199 additions and 897 deletions
+20 -3
View File
@@ -5,6 +5,13 @@ on:
branches:
- 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:
REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
@@ -23,12 +30,22 @@ jobs:
uses: actions/setup-node@v4
with:
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
if: steps.node-modules-cache.outputs.cache-hit != 'true'
working-directory: nextjs-app
run: npm ci
run: npm ci --prefer-offline --no-audit --no-fund
- name: Typecheck
working-directory: nextjs-app
+82 -71
View File
@@ -30,6 +30,7 @@ from .data_loader import (
load_latest_school_data,
geocode_single_postcode,
get_supplementary_data,
get_supplementary_data_batch,
search_schools_typesense,
)
from .data_loader import get_data_info as get_db_info
@@ -679,8 +680,10 @@ async def compare_schools(
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 = get_supplementary_data(db, urn)
supp = batch.get(urn, {})
supplementary_by_urn[urn] = {
key: supp.get(key, default)
for key, default in _EMPTY_SUPPLEMENTARY.items()
@@ -772,93 +775,101 @@ async def get_la_averages(request: Request):
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
_KS2_NATIONAL_METRICS = [
"rwm_expected_pct", "rwm_high_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_progress", "writing_progress", "maths_progress",
"overall_absence_pct", "persistent_absence_pct",
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct",
]
_KS4_NATIONAL_METRICS = [
"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", "gcse_grade_91_pct",
]
def _national_averages_payload(df: pd.DataFrame) -> dict:
"""National-averages payload shared by /api/national-averages and
/api/compare. Official DfE KS2 figures come from the mart table;
KS4 figures are computed from our dataset (no DfE dataset yet)."""
/api/compare.
Both series are persisted marts computed at import time: official DfE
KS2 figures (fact_ks2_national_averages) and dataset-computed KS4
averages (fact_ks4_national_averages) — the API never aggregates the
performance dataframe per request. If the KS4 mart hasn't been built
yet (deploy lands before the next DAG run), fall back to computing the
latest year only — a single-year scan, never the historical loop.
"""
if df.empty:
return {"primary": {}, "secondary": {}}
ks2_metrics = [
"rwm_expected_pct", "rwm_high_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_progress", "writing_progress", "maths_progress",
"overall_absence_pct", "persistent_absence_pct",
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct",
]
ks4_metrics = [
"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", "gcse_grade_91_pct",
]
latest_year = int(df["year"].max())
def _means(sub_df, metric_list):
from . import database
from .models import Ks2NationalAverage, Ks4NationalAverage
def _row_metrics(row, metric_list):
out = {}
for col in metric_list:
if col in sub_df.columns:
val = sub_df[col].dropna()
if len(val) > 0:
out[col] = round(float(val.mean()), 2)
val = getattr(row, col, None)
if val is not None:
out[col] = val
return out
latest_year = int(df["year"].max())
df_latest = df[df["year"] == latest_year]
# Primary: schools where KS2 data is non-null
primary_df = df_latest[df_latest["rwm_expected_pct"].notna()]
# 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)
latest_secondary = _means(secondary_df, ks4_metrics)
# Per-year KS2 primary averages: use official DfE figures from the mart table.
# Per-year KS4 secondary averages: computed from our dataset (no DfE dataset yet).
from . import database
from .models import Ks2NationalAverage
by_year = []
ks2_rows: list = []
ks4_rows: list = []
db = None
try:
db = database.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:
primary_yr[col] = val
by_year.append({
"year": yr,
"primary": primary_yr,
"secondary": secondary_by_year.get(yr, {}),
})
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:
if db is not None:
db.close()
# Update latest_primary with official DfE figure for the latest year if available
if by_year:
latest_official = next((e["primary"] for e in reversed(by_year) if e["primary"]), None)
if latest_official:
latest_primary = latest_official
primary_by_year = {r.year: _row_metrics(r, _KS2_NATIONAL_METRICS) for r in ks2_rows}
secondary_by_year = {r.year: _row_metrics(r, _KS4_NATIONAL_METRICS) for r in ks4_rows}
if not any(secondary_by_year.values()):
# KS4 mart missing/empty: compute the latest year only.
df_latest = df[df["year"] == latest_year]
sec = (
df_latest[df_latest["attainment_8_score"].notna()]
if "attainment_8_score" in df_latest.columns
else df_latest.iloc[0:0]
)
vals = {}
for col in _KS4_NATIONAL_METRICS:
if col in sec.columns:
v = sec[col].dropna()
if len(v) > 0:
vals[col] = round(float(v.mean()), 2)
if vals:
secondary_by_year[latest_year] = vals
all_years = sorted(set(primary_by_year) | set(secondary_by_year))
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 {
"year": latest_year,
+133 -75
View File
@@ -662,92 +662,150 @@ def _admissions_row_dict(a) -> dict:
}
def get_supplementary_data(db: Session, urn: int) -> dict:
"""Fetch all supplementary data for a single school URN."""
result = {}
def _census_dict(pc) -> dict:
return {
"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,
}
def safe_query(model, pk_field, latest_field=None):
def _deprivation_dict(d) -> dict:
return {
"lsoa_code": d.lsoa_code,
"idaci_score": d.idaci_score,
"idaci_decile": d.idaci_decile,
}
def _finance_dict(f) -> dict:
return {
"year": f.year,
"per_pupil_spend": f.per_pupil_spend,
"staff_cost_pct": f.staff_cost_pct,
"teacher_cost_pct": f.teacher_cost_pct,
"support_staff_cost_pct": f.support_staff_cost_pct,
"premises_cost_pct": f.premises_cost_pct,
}
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:
q = db.query(model).filter(getattr(model, pk_field) == urn)
if latest_field:
q = q.order_by(getattr(model, latest_field).desc())
return q.first()
fn()
except Exception as e:
import logging
logging.getLogger(__name__).error("safe_query failed for %s: %s", model.__name__, e)
logging.getLogger(__name__).error("batch supplementary query failed: %s", e)
db.rollback()
return None
# Latest Ofsted inspection
o = safe_query(FactOfstedInspection, "urn", "inspection_date")
result["ofsted"] = _ofsted_block(o, urn) if o else None
# Census (latest year of fact_pupil_characteristics)
pc = safe_query(FactPupilCharacteristics, "urn", "year")
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).
try:
admissions_rows = (
db.query(FactAdmissions)
.filter(FactAdmissions.urn == urn)
.order_by(FactAdmissions.year.asc())
# 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()
)
except Exception as e:
import logging
logging.getLogger(__name__).error("admissions history query failed: %s", e)
db.rollback()
admissions_rows = []
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)
history = [_admissions_row_dict(a) for a in admissions_rows]
result["admissions_history"] = history
# Keep the single latest-year object for backwards-compatible consumers
# (hero chips, etc.).
result["admissions"] = history[-1] if history else None
# 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)
# SEN detail — not available in current marts
result["sen_detail"] = None
# 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)
# Phonics — no school-level data on EES
result["phonics"] = None
# 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)
# Deprivation
d = safe_query(FactDeprivation, "urn")
result["deprivation"] = (
{
"lsoa_code": d.lsoa_code,
"idaci_score": d.idaci_score,
"idaci_decile": d.idaci_decile,
}
if d
else None
)
# Finance (latest year)
f = safe_query(FactFinance, "urn", "year")
result["finance"] = (
{
"year": f.year,
"per_pupil_spend": f.per_pupil_spend,
"staff_cost_pct": f.staff_cost_pct,
"teacher_cost_pct": f.teacher_cost_pct,
"support_staff_cost_pct": f.support_staff_cost_pct,
"premises_cost_pct": f.premises_cost_pct,
}
if f
else None
)
# 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
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)]
+17
View File
@@ -231,6 +231,23 @@ class FactFinance(Base):
premises_cost_pct = Column(Float)
class Ks4NationalAverage(Base):
"""Computed national KS4 averages (from our dataset) — one row per year."""
__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):
"""Official DfE KS2 national headline averages — one row per academic year."""
__tablename__ = "fact_ks2_national_averages"
+5 -3
View File
@@ -67,7 +67,9 @@ def client(monkeypatch):
monkeypatch.setattr(app_module, "load_school_data", _two_primary_schools_df)
monkeypatch.setattr(
app_module, "get_supplementary_data", lambda db, urn: dict(CANNED_SUPPLEMENTARY)
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)
@@ -102,10 +104,10 @@ def test_top_level_national_averages_and_benchmarks(client):
def test_supplementary_failure_degrades_not_500(client, monkeypatch):
from backend import app as app_module
def _boom(db, urn):
def _boom(db, urns):
raise RuntimeError("marts unavailable")
monkeypatch.setattr(app_module, "get_supplementary_data", _boom)
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"]
@@ -0,0 +1,93 @@
"""_national_averages_payload reads persisted marts (computed at import
time) — it must never loop the dataframe per year. The only dataframe work
allowed is the single-latest-year KS4 fallback for the window between a
deploy and the next DAG run."""
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_missing_ks4_mart_falls_back_to_latest_year_only(payload):
body = payload(_Ks4MissingSession)
# Fallback computes the latest year from the df: mean(50, 30) = 40.0
assert body["secondary"]["attainment_8_score"] == 40.0
# ...and only the latest year — no historical KS4 loop
ks4_years = [e["year"] for e in body["by_year"] if e["secondary"]]
assert ks4_years == [LATEST]
+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,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,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>
+90 -14
View File
@@ -19,6 +19,27 @@ function schoolLinks(page: Page) {
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]];
}
test('home page loads with hero search', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1').first()).toBeVisible();
@@ -138,20 +159,44 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
await expect(openFs).toBeVisible();
});
test('comparing two schools shows both side by side', async ({ page }) => {
// Collect two school URNs from search results, then load the share URL
await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
const hrefs = await schoolLinks(page).evaluateAll((links) =>
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
);
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
expect(urns.length).toBeGreaterThanOrEqual(2);
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=${urns[0]},${urns[1]}`);
await page.goto(`/compare?urns=${urn0},${urn1}`);
// Both schools' detail links should render in the comparison view
await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible();
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();
// 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('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
@@ -170,9 +215,40 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
expect(urns.length).toBeGreaterThanOrEqual(3);
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
// The mobile chart legend renders one chip per school in the active phase.
// Mobile is measure-first: the At a glance section stacks all active-phase
// 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 });
@@ -0,0 +1,106 @@
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('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,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,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,84 @@
/**
* 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('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,259 @@
/**
* 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,
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('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('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('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]);
});
});
+15
View File
@@ -10,6 +10,7 @@ import {
debounce,
buildOfstedListBadge,
metricKind,
shortName,
computeYBounds,
} from '@/lib/utils';
@@ -223,3 +224,17 @@ describe('isProposedToClose', () => {
expect(isProposedToClose({})).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…');
});
});
+17 -17
View File
@@ -16,8 +16,10 @@ interface ComparePageProps {
export const metadata: Metadata = {
title: 'Compare Schools',
description: 'Compare KS2 performance across multiple primary schools in England',
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
description:
'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
@@ -30,26 +32,24 @@ export default async function ComparePage({ searchParams }: ComparePageProps) {
const selectedMetric = metricParam || 'rwm_expected_pct';
try {
// Fetch comparison data if URNs provided
let comparisonData = null;
if (urns.length > 0) {
try {
const response = await fetchComparison(urnsParam!);
comparisonData = response.comparison;
} catch (error) {
console.error('Failed to fetch comparison:', error);
}
}
// Fetch comparison + metrics in parallel — they are independent.
const [comparisonResponse, metricsResponse] = await Promise.all([
urns.length > 0
? fetchComparison(urnsParam!).catch((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 || [];
return (
<ComparisonView
initialData={comparisonData}
initialData={comparisonResponse?.comparison ?? null}
initialNationalAverages={comparisonResponse?.national_averages}
initialBenchmarks={comparisonResponse?.benchmarks}
initialUrns={urns}
metrics={metricsArray}
selectedMetric={selectedMetric}
@@ -65,3 +65,9 @@
min-width: 0;
}
}
.chartNote {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0.5rem 0 0;
}
+40 -16
View File
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
import '@/lib/chartSetup';
import { buildCompareChart } from '@/lib/compareChartData';
import type { ComparisonData } from '@/lib/types';
import {
CHART_COLORS,
@@ -34,13 +35,16 @@ interface ComparisonChartProps {
schools: Array<{ urn: number; school_name: string }>;
metric: 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>;
}
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) {
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
@@ -54,34 +58,48 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
return <div>No data available</div>;
}
// Union of years across all schools — coverage differs between them.
const years = [
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
].sort((a, b) => a - b);
// Pure, tested series construction: union of years with cancelled /
// unpublished years kept as real gaps, plus the England overlay.
const built = buildCompareChart(comparisonData, schools, metric, nationalByYear);
const { years } = built;
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
const data = comparisonData[String(school.urn)];
const color = CHART_COLORS[index % CHART_COLORS.length];
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
const school = schools[series.schoolIndex];
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
return {
label: school.school_name,
data: years.map((year) => {
const yearData = data?.yearly_data.find((d) => d.year === year);
if (!yearData) return null;
return yearData[metric as keyof typeof yearData] as number | null;
}),
label: series.label,
data: series.data,
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
pointStyle: POINT_STYLES[index % POINT_STYLES.length],
pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length],
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
pointHoverRadius: isMobile ? 5 : 6,
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 = {
labels: years.map(formatAcademicYear),
datasets,
@@ -222,6 +240,12 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
<div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div>
{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>
);
}
+105 -370
View File
@@ -28,8 +28,15 @@
color: var(--text-secondary, #5c564d);
margin: 0;
line-height: 1.6;
max-width: 60ch;
}
.headerActions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
/* Phase Tabs */
.phaseTabs {
@@ -72,408 +79,136 @@
background: var(--accent-coral-darker, #9c3f26);
}
/* Metric Selector */
.metricSelector {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
/* Sticky school bar — column identity while scrolling; horizontal scroll on
narrow screens. Offset by the sticky site header's height (Navigation is
position: sticky, top: 0) so this bar pins just below it instead of
sliding underneath and being hidden. Header ≈ 65px desktop / 57px mobile. */
.schoolBar {
position: sticky;
top: 65px;
z-index: 10;
background: var(--bg-primary, #faf7f2);
display: flex;
align-items: center;
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 {
gap: 0.75rem;
overflow-x: auto;
max-width: 100%;
margin-top: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light, #e5dfd5);
-webkit-overflow-scrolling: touch;
}
/* Right-edge fade so phone users see the comparison table scrolls.
Otherwise the wider-than-viewport table silently clips. */
@media (max-width: 640px) {
.tableWrapper {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
.schoolChip {
flex: 1 1 0;
min-width: 180px;
background: var(--bg-card, white);
border: 1px solid var(--border-light, #e5dfd5);
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 {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.9375rem;
.chipDot {
width: 11px;
height: 11px;
border-radius: 50%;
flex: none;
}
.comparisonTable thead {
background: var(--bg-secondary, #f3ede4);
.chipText {
min-width: 0;
}
.comparisonTable th {
padding: 1rem;
text-align: left;
.chipName {
display: block;
font-weight: 600;
font-size: 0.92rem;
line-height: 1.25;
color: var(--text-primary, #1a1612);
border-bottom: 2px solid var(--border-color, #e5dfd5);
background: var(--bg-secondary, #f3ede4);
text-decoration: none;
}
.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;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
overflow: hidden;
text-overflow: ellipsis;
}
.comparisonTable td {
padding: 1rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
color: var(--text-secondary, #5c564d);
text-align: left;
background: var(--bg-card, white);
}
/* Sticky first column (Year) so labels remain visible while scrolling */
.comparisonTable th:first-child,
.comparisonTable td:first-child {
position: sticky;
left: 0;
z-index: 1;
box-shadow: 2px 0 4px -2px rgba(26, 22, 18, 0.08);
}
.comparisonTable thead th:first-child {
z-index: 2;
}
.comparisonTable tbody tr:hover td:first-child {
.chipRemove {
margin-left: auto;
border: none;
background: var(--bg-secondary, #f3ede4);
color: var(--text-muted, #6d685f);
border-radius: 50%;
width: 22px;
height: 22px;
cursor: pointer;
flex: none;
font-size: 0.9rem;
line-height: 1;
}
.comparisonTable tbody tr:last-child td {
border-bottom: none;
.footnote {
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;
}
.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;
color: var(--text-primary, #1a1612);
margin-bottom: 0.5rem;
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.emptyStateDescription {
font-size: 1rem;
color: var(--text-secondary, #5c564d);
max-width: 400px;
margin: 0 auto 1.5rem;
}
.metricDescription {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
max-width: 600px;
flex-basis: 100%;
margin-top: 0.25rem;
}
.progressNote {
background: var(--bg-secondary);
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;
}
/* Responsive Design */
@media (max-width: 768px) {
.headerContent {
flex-direction: column;
align-items: stretch;
/* 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;
}
.header h1 {
font-size: 1.75rem;
.schoolChip {
flex: 0 0 auto;
min-width: 0;
border-top-width: 2px;
border-radius: 999px;
padding: 0.35rem 0.7rem;
box-shadow: none;
}
.metricSelector {
flex-direction: column;
align-items: stretch;
padding: 1rem;
border-radius: 8px;
.chipName {
font-size: 0.85rem;
white-space: nowrap;
}
.metricSelect {
max-width: 100%;
.chipNameFull {
display: none;
}
.schoolsGrid {
grid-template-columns: 1fr;
.chipNameShort {
display: inline;
}
.chartSection,
.tableSection {
padding: 1rem;
border-radius: 8px;
.chipMeta {
display: none;
}
.chartContainer {
/* Taller than desktop's proportion would suggest: the chip legend row
sits inside, and the in-chart title/legend/axis titles are gone, so
nearly all of this is plot area. */
height: 340px;
}
.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;
.chipRemove {
width: 18px;
height: 18px;
font-size: 0.75rem;
}
}
+202 -287
View File
@@ -1,49 +1,42 @@
/**
* ComparisonView Component
* Client-side comparison interface with phase tabs, charts, and tables
* ComparisonView — the parent-first compare screen: a sticky school bar and
* 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';
import { useEffect, useRef, useState } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import dynamic from 'next/dynamic';
import { useComparison } from '@/hooks/useComparison';
const ComparisonChart = dynamic(
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
{ ssr: false },
);
import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton';
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils';
import { CompareAtAGlance } from './compare/CompareAtAGlance';
import { CompareOfsted } from './compare/CompareOfsted';
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 { track } from '@/lib/analytics';
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 {
initialData: Record<string, ComparisonData> | null;
initialNationalAverages?: NationalAverages;
initialBenchmarks?: Benchmarks;
initialUrns: number[];
metrics: MetricDefinition[];
selectedMetric: string;
@@ -51,6 +44,8 @@ interface ComparisonViewProps {
export function ComparisonView({
initialData,
initialNationalAverages,
initialBenchmarks,
initialUrns,
metrics,
selectedMetric: initialMetric,
@@ -58,11 +53,15 @@ export function ComparisonView({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison();
const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
const [isModalOpen, setIsModalOpen] = useState(false);
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 [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
// Tracks whether the user has explicitly clicked a phase tab.
@@ -77,24 +76,27 @@ export function ComparisonView({
if (!isInitialized) return;
if (initialUrns.length > 0 && initialData) {
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));
const sameSet =
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) {
replaceSchools(urlSchools);
}
}
}, [isInitialized]); // 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(() => {
const urns = selectedSchools.map((s) => s.urn).join(',');
const params = new URLSearchParams(searchParams);
if (urns) {
params.set('urns', urns);
if (urnKey) {
params.set('urns', urnKey);
} else {
params.delete('urns');
}
@@ -103,23 +105,42 @@ export function ComparisonView({
const newUrl = `${pathname}?${params.toString()}`;
router.replace(newUrl, { scroll: false });
}, [urnKey, selectedMetric, pathname, searchParams, router]);
// Fetch comparison data
if (selectedSchools.length > 0) {
fetchComparison(urns, { cache: 'no-store' })
.then((data) => {
setComparisonData(data.comparison);
})
.catch((err) => {
// Keep whatever we already have (SSR data or a previous fetch) rather
// than blanking the chart — a transient refetch failure shouldn't
// destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err);
});
} else {
setComparisonData(null);
}
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
// Fetch when the school set changes, but only for schools we don't already
// have data for. This skips the refetch of SSR-rendered data on load AND
// 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;
fetchComparison(urnKey, { cache: 'no-store' })
.then((data) => {
setComparisonData(data.comparison);
setNationalAverages(data.national_averages);
setBenchmarks(data.benchmarks);
})
.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);
});
}, [urnKey, isInitialized]);
// Classify schools by phase using comparison data
const classifySchool = (school: School): 'primary' | 'secondary' => {
@@ -128,27 +149,22 @@ export function ComparisonView({
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';
if (yearlyData?.some((d) => d.attainment_8_score != null)) return 'secondary';
return 'primary';
};
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
const primarySchools = selectedSchools.filter((s) => classifySchool(s) === 'primary');
const secondarySchools = selectedSchools.filter((s) => classifySchool(s) === 'secondary');
// Auto-select tab with more schools and sync the metric to match the detected phase.
// This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct)
// but the shortlisted schools are secondary — the phase tab switches but the metric
// needs to follow, otherwise all secondary cards show "" for a primary-only field.
// Auto-select tab with more schools and sync the metric to match the phase.
useEffect(() => {
if (!comparisonData || selectedSchools.length === 0) return;
if (phaseLockedByUser.current) return;
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
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 metricFitsPhase = metrics.some(
(m) => m.key === selectedMetric && phaseCategories.includes(m.category)
(m) => m.key === selectedMetric && phaseCategories.includes(m.category),
);
if (!metricFitsPhase) {
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
@@ -158,29 +174,24 @@ export function ComparisonView({
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
phaseLockedByUser.current = true;
setComparePhase(phase);
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
setSelectedMetric(defaultMetric);
setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
};
// 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);
useEffect(() => {
if (compareViewedRef.current) return;
if (selectedSchools.length === 0) return;
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 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 });
}, [selectedSchools]);
const handleMetricChange = (metric: string) => {
track('compare_metric_changed', { metric, phase: comparePhase });
setSelectedMetric(metric);
};
const handleRemoveSchool = (urn: number) => {
removeSchool(urn);
track('compare_school_removed', { urn, from: 'compare' });
@@ -191,21 +202,22 @@ export function ComparisonView({
const count = selectedSchools.length;
const shareData = {
title: 'School comparison · SchoolCompare',
text: count > 0
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
: 'SchoolCompare',
text:
count > 0
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
: 'SchoolCompare',
url,
};
// Prefer the native share sheet on platforms that support it (iOS / Android).
// canShare is feature-detected because Safari iOS exposes share() but
// some configurations refuse the payload.
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
if (
typeof navigator !== 'undefined' &&
navigator.share &&
(!navigator.canShare || navigator.canShare(shareData))
) {
try {
await navigator.share(shareData);
track('compare_shared', { method: 'native', school_count: count });
return;
} catch (err) {
// User cancelled — bail silently. Any other error falls through to clipboard.
if ((err as DOMException)?.name === 'AbortError') return;
}
}
@@ -214,27 +226,22 @@ export function ComparisonView({
track('compare_shared', { method: 'clipboard', school_count: count });
setShareConfirm(true);
setTimeout(() => setShareConfirm(false), 2000);
} catch { /* fallback: do nothing */ }
} catch {
/* fallback: do nothing */
}
};
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;
// Get metric definition
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
const metricLabel = currentMetricDef?.label || selectedMetric;
// No schools selected
if (selectedSchools.length === 0) {
return (
<div className={styles.container}>
<header className={styles.header}>
<h1>Compare Schools</h1>
<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>
</header>
@@ -252,39 +259,46 @@ export function ComparisonView({
);
}
// Build filtered comparison data for active phase
// Build filtered comparison data for the active phase
const activeComparisonData: Record<string, ComparisonData> = {};
if (comparisonData) {
activeSchools.forEach(s => {
activeSchools.forEach((s) => {
if (comparisonData[s.urn]) {
activeComparisonData[s.urn] = comparisonData[s.urn];
}
});
}
// Get years for table
const years =
Object.keys(activeComparisonData).length > 0
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
: [];
const hasData = Object.keys(activeComparisonData).length > 0;
return (
<div className={styles.container}>
{/* Header */}
<header className={styles.header}>
<div className={styles.headerContent}>
<div>
<h1>Compare Schools</h1>
<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>
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<div className={styles.headerActions}>
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
+ Add School
</button>
<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'}
</button>
</div>
@@ -292,20 +306,22 @@ export function ComparisonView({
</header>
{/* Phase Tabs */}
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
Primary ({primarySchools.length})
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary ({secondarySchools.length})
</button>
</div>
{secondarySchools.length > 0 && primarySchools.length > 0 && (
<div className={styles.phaseTabs}>
<button
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('primary')}
>
Primary ({primarySchools.length})
</button>
<button
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
onClick={() => handlePhaseChange('secondary')}
>
Secondary ({secondarySchools.length})
</button>
</div>
)}
{activeSchools.length === 0 ? (
<EmptyState
@@ -318,184 +334,83 @@ export function ComparisonView({
/>
) : (
<>
{/* Metric Selector */}
<section className={styles.metricSelector}>
<label htmlFor="metric-select" className={styles.metricLabel}>
Select Metric:
</label>
<select
id="metric-select"
value={selectedMetric}
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
>
{optgroups.map(({ label, category }) => {
const groupMetrics = filteredMetrics.filter(m => m.category === category);
if (groupMetrics.length === 0) return null;
return (
<optgroup key={category} label={label}>
{groupMetrics.map((metric) => (
<option key={metric.key} value={metric.key}>{metric.label}</option>
))}
</optgroup>
);
})}
</select>
{currentMetricDef?.description && (
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
)}
</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) => (
<div
key={school.urn}
className={styles.schoolCard}
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
>
<button
onClick={() => handleRemoveSchool(school.urn)}
className={styles.removeButton}
aria-label={`Remove ${school.school_name}`}
title="Remove from comparison"
>
×
</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>
</section>
{/* Comparison Chart */}
{Object.keys(activeComparisonData).length > 0 ? (
<section className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
<div className={styles.chartContainer}>
<ComparisonChart
comparisonData={activeComparisonData}
schools={activeSchools}
metric={selectedMetric}
metricLabel={metricLabel}
{/* Sticky school bar — column identity while scrolling */}
<div className={styles.schoolBar} aria-label="Schools in this comparison">
{activeSchools.map((school, index) => (
<div
key={school.urn}
className={styles.schoolChip}
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}>
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
</span>
</span>
<button
onClick={() => handleRemoveSchool(school.urn)}
className={styles.chipRemove}
aria-label={`Remove ${school.school_name}`}
title="Remove from comparison"
>
×
</button>
</div>
</section>
) : activeSchools.length > 0 ? (
<section className={styles.chartSection}>
<LoadingSkeleton type="list" />
</section>
) : null}
))}
</div>
{/* Comparison Table */}
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
<section className={styles.tableSection}>
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
<div className={styles.tableWrapper}>
<table className={styles.comparisonTable}>
<thead>
<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>;
{hasData && (
<>
<CompareAtAGlance
schools={activeSchools}
data={activeComparisonData}
nationalAverages={nationalAverages}
benchmarks={benchmarks}
/>
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
<CompareAcademics
schools={activeSchools}
data={activeComparisonData}
nationalAverages={nationalAverages}
benchmarks={benchmarks}
/>
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
<CompareCommunity
schools={activeSchools}
data={activeComparisonData}
benchmarks={benchmarks}
/>
<TrendsExplorer
schools={activeSchools}
data={activeComparisonData}
metrics={metrics}
metric={selectedMetric}
onMetricChange={setSelectedMetric}
isPrimaryPhase={isPrimary}
nationalAverages={nationalAverages}
/>
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>
<p className={styles.footnote}>
Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection
outcomes, DfE school admissions data, school census. England averages for test
results are official DfE figures; other benchmarks are state-school averages
computed from our dataset. Following DfE practice, figures based on 5 or fewer
pupils are suppressed and shown as &quot;no data&quot;.
</p>
</>
)}
</>
)}
{/* School Search Modal */}
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</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>
);
}
@@ -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,292 @@
/**
* 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 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,
}: {
spec: StripSpec;
data: Record<string, ComparisonData>;
urns: number[];
schoolNames: string[];
national: Record<string, number> | undefined;
}) {
const values = latestValues(data, urns, spec.metric).map((v) =>
v != null ? 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,
}: {
schools: School[];
data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages;
benchmarks?: Benchmarks;
}) {
const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name);
const isSecondary = schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
);
if (isSecondary) {
const att8 = 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;
});
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
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>
{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>
) : (
<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 ? `${Math.round(grade5[i] as number)}%` : <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 ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>}
</Cell>
))}
</SectionGrid>
</Section>
);
}
const national = nationalAverages?.primary;
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? 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}
/>
))}
<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}
/>
))}
<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>{' '}
{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,130 @@
/**
* 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 { 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,
}: {
schools: School[];
data: Record<string, ComparisonData>;
}) {
const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null);
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 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}>No data</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);
let text: string | null = null;
if (summary.firstPrefPct != null) {
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 =
'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,183 @@
/**
* 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 {
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,
}: {
schools: School[];
data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages;
benchmarks?: Benchmarks;
}) {
const urns = schools.map((school) => school.urn);
const isSecondary = 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 === '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) => {
const summary = summariseAdmissions(data[String(school.urn)]?.admissions);
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;
let sizeNote: string | null = null;
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,194 @@
/**
* 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,
}: {
schools: School[];
data: Record<string, ComparisonData>;
benchmarks?: Benchmarks;
}) {
const isSecondary = 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) => {
if (value == null || bench?.disadvantaged_pct == null) return null;
const v = verdict(value, bench.disadvantaged_pct, 3);
return (
<Chip tone="neutral">
{v === 'above' && 'Above the state-school average'}
{v === 'close' && 'About the state-school average'}
{v === 'below' && 'Below the state-school average'}
</Chip>
);
};
return (
<Section
title="Who goes there"
how="The school's community, from the latest school census. State-school averages are computed from our dataset and shown for context — there's no “right” number here."
>
<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,225 @@
/**
* 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>
</>
);
}
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]> = [
['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],
];
const published = legacyAreas.filter(([, grade]) => grade != null);
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;
const age = yearsSince(ofsted?.inspection_date ?? null);
return (
<Cell key={school.urn} school={school} index={i}>
{formatInspectionDate(ofsted?.inspection_date ?? null)}{' '}
{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}>
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer">
{school.school_name}&apos;s Ofsted page
</a>
</Cell>
);
})}
</Measure>
</SectionGrid>
</Section>
);
}
@@ -0,0 +1,77 @@
.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 runs Chart.js with maintainAspectRatio:false, so it fills
its container's height — which must be *definite*. A min-height alone does
not resolve the chart wrapper's height:100%, leaving Chart.js to fall back
to its ~150px default (a squashed sliver). Give it a real height. */
.chartBox {
height: 420px;
}
@media (max-width: 640px) {
/* Taller on mobile: the mobile-only school chips sit above the canvas and
wrap to two rows for 3+ schools, so the plot keeps a usable height. */
.chartBox {
height: 360px;
}
}
@@ -0,0 +1,128 @@
/**
* 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}
/>
</div>
</div>
</details>
</Section>
);
}
@@ -0,0 +1,264 @@
/* 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%;
font-size: 0.8rem;
font-weight: 600;
color: var(--sc, var(--text-secondary));
margin-bottom: 0.15rem;
}
.cellDot {
width: 9px;
height: 9px;
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
* Uses shared context for real-time updates across components
* Custom hook for managing school comparison state.
*
* 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';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import { useComparisonContext } from '@/context/ComparisonContext';
import type { ComparisonResponse } from '@/lib/types';
export function useComparison() {
const {
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,
};
return useComparisonContext();
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Pure series-building for the comparison trend chart, extracted from
* ComparisonChart so it is unit-testable without a canvas.
*
* Chart truthfulness rules (spec §8.1): every academic year between the
* first and last data point appears on the axis — cancelled test years
* (2019/20, 2020/21) and the unpublished 2021/22 school-level year render
* as real gaps, never as compressed time; school lines never bridge gaps.
*/
import type { ComparisonData } from './types';
/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */
function nextAcademicYear(year: number): number {
const start = Math.floor(year / 100);
const end = year % 100;
return (start + 1) * 100 + (end + 1);
}
/** Every academic year from min(years) to max(years), inclusive. */
export function fillAcademicYears(years: number[]): number[] {
if (years.length === 0) return [];
const ints = [...new Set(years.map((y) => Math.trunc(y)))].sort((a, b) => a - b);
const out: number[] = [];
let y = ints[0];
const last = ints[ints.length - 1];
while (y <= last && out.length < 50) {
out.push(y);
y = nextAcademicYear(y);
}
return out;
}
export interface CompareChartSeries {
label: string;
data: Array<number | null>;
/** Index into CHART_COLORS / point styles. */
schoolIndex: number;
spanGaps: false;
}
export interface EnglandSeries {
label: 'England average';
data: Array<number | null>;
borderDash: [number, number];
spanGaps: false;
}
export interface CompareChart {
years: number[];
schoolDatasets: CompareChartSeries[];
englandDataset: EnglandSeries | null;
/** True when England published a 2021/22 figure but no school has one —
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */
showUnpublished202122Note: boolean;
}
export function buildCompareChart(
comparisonData: Record<string, ComparisonData>,
schools: Array<{ urn: number; school_name: string }>,
metric: string,
nationalByYear?: Record<number, number | null | undefined>,
): CompareChart {
const rawYears = schools.flatMap(
(s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [],
);
const years = fillAcademicYears(rawYears);
const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => {
const rows = comparisonData[String(school.urn)]?.yearly_data ?? [];
const byYear = new Map<number, Record<string, unknown>>();
for (const row of rows) byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
return {
label: school.school_name,
data: years.map((year) => {
const v = byYear.get(year)?.[metric];
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
}),
schoolIndex,
spanGaps: false,
};
});
let englandDataset: EnglandSeries | null = null;
if (nationalByYear) {
const data = years.map((year) => {
const v = nationalByYear[year];
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
});
if (data.some((v) => v != null)) {
englandDataset = { label: 'England average', data, borderDash: [5, 4], spanGaps: false };
}
}
const idx202122 = years.indexOf(202122);
const showUnpublished202122Note =
idx202122 >= 0 &&
englandDataset?.data[idx202122] != null &&
schoolDatasets.every((ds) => ds.data[idx202122] == null);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note };
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Comprehension rules for the compare screen, kept pure and unit-tested.
*
* These encode the expert-review requirements (spec §8 of the compare
* redesign): report-card summaries count graded areas only (safeguarding is
* a separate binary judgement), problem areas are always NAMED rather than
* folded into counts, grade labels pass through from the API (live-sampled
* Ofsted vocabulary — never invented here), admissions chips use one
* consistent metric, and progress bands follow DfE's confidence-interval
* methodology instead of thresholding point estimates.
*/
import type { OfstedInspection, SchoolAdmissions } from './types';
// ---------------------------------------------------------------------------
// Verdicts against an anchor (England average or state-school benchmark)
// ---------------------------------------------------------------------------
export type Verdict = 'above' | 'close' | 'below';
export function verdict(value: number, anchor: number, tolerance = 2): Verdict {
if (value >= anchor + tolerance) return 'above';
if (value <= anchor - tolerance) return 'below';
return 'close';
}
// ---------------------------------------------------------------------------
// Ofsted — three regimes, one display model
// ---------------------------------------------------------------------------
export const OFSTED_LEGACY_GRADES: Record<number, string> = {
1: 'Outstanding',
2: 'Good',
3: 'Requires improvement',
4: 'Inadequate',
};
/** rc_ key → the area label used across the reviewed mockups. */
const RC_AREA_LABELS: Record<string, string> = {
rc_inclusion: 'Inclusion',
rc_curriculum_teaching: 'Curriculum & teaching',
rc_achievement: 'Achievement',
rc_attendance_behaviour: 'Attendance & behaviour',
rc_personal_development: 'Personal development',
rc_leadership_governance: 'Leadership & governance',
rc_early_years: 'Early years',
rc_sixth_form: 'Sixth form',
};
export function rcAreaLabel(key: string): string {
return RC_AREA_LABELS[key] ?? key;
}
export interface ReportCardSummary {
/** Graded areas only, grouped by label, best grade first. */
counts: Array<{ label: string; count: number }>;
/** Areas rated Needs attention / Urgent improvement — always named. */
problems: Array<{ areaLabel: string; label: string }>;
safeguarding: 'met' | 'not_met' | null;
/** True when every graded area is Expected standard or better and
* safeguarding is not "not met". */
allClear: boolean;
}
const PROBLEM_CODES = new Set([4, 5]);
export function summariseReportCard(ofsted: OfstedInspection): ReportCardSummary {
const entries = Object.entries(ofsted.report_card ?? {});
const byCode = new Map<number, { label: string; count: number }>();
const problems: ReportCardSummary['problems'] = [];
for (const [key, entry] of entries) {
if (PROBLEM_CODES.has(entry.code)) {
problems.push({ areaLabel: rcAreaLabel(key), label: entry.label });
} else {
const existing = byCode.get(entry.code);
if (existing) existing.count += 1;
else byCode.set(entry.code, { label: entry.label, count: 1 });
}
}
const counts = [...byCode.entries()]
.sort(([a], [b]) => a - b)
.map(([, v]) => v);
const safeguarding =
ofsted.rc_safeguarding_met === true
? 'met'
: ofsted.rc_safeguarding_met === false
? 'not_met'
: null;
return {
counts,
problems,
safeguarding,
allClear: entries.length > 0 && problems.length === 0 && safeguarding !== 'not_met',
};
}
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: OfstedInspection | null | undefined,
): OfstedDisplay {
if (!ofsted) return { kind: 'none' };
// A report card is the newest inspection format; when present it wins —
// never derive or prefer an overall grade alongside it.
if (ofsted.report_card && Object.keys(ofsted.report_card).length > 0) {
return { kind: 'report_card', summary: summariseReportCard(ofsted) };
}
const grade = ofsted.overall_effectiveness;
const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined;
if (grade == null || gradeLabel === undefined) return { kind: 'none' };
if (ofsted.grade_source === 'ungraded_carried_forward') {
return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true };
}
return { kind: 'graded', grade, gradeLabel, carriedForward: false };
}
// ---------------------------------------------------------------------------
// Admissions — one consistent chip metric (first-preference success)
// ---------------------------------------------------------------------------
export interface AdmissionsSummary {
firstPrefPct: number | null;
chip: { tone: 'good' | 'warn' | 'neutral'; text: string } | null;
/** e.g. "Named on 457 forms · 180 places" — total preferences at any rank,
* deliberately not phrased as head-to-head applications. */
interest: string | null;
}
export function summariseAdmissions(
a: SchoolAdmissions | null | undefined,
): AdmissionsSummary {
if (!a) return { firstPrefPct: null, chip: null, interest: null };
const pct =
a.first_preference_offer_pct != null
? Math.round(a.first_preference_offer_pct)
: null;
let chip: AdmissionsSummary['chip'] = null;
if (pct != null) {
if (pct >= 100) {
chip = { tone: 'good', text: 'All first choices offered' };
} else if (pct < 75) {
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
} else {
chip = { tone: pct >= 90 ? 'good' : 'neutral', text: `${pct}% of first choices offered` };
}
}
const interest =
a.total_applications != null && a.places_offered != null
? `Named on ${a.total_applications.toLocaleString('en-GB')} forms · ${a.places_offered.toLocaleString('en-GB')} places`
: null;
return { firstPrefPct: pct, chip, interest };
}
// ---------------------------------------------------------------------------
// Progress bands — DfE confidence-interval methodology
// ---------------------------------------------------------------------------
export function progressBand(
score: number | null,
lower: number | null,
upper: number | null,
): 'above' | 'average' | 'below' | null {
if (score == null || lower == null || upper == null) return null;
if (lower > 0) return 'above';
if (upper < 0) return 'below';
return 'average';
}
// ---------------------------------------------------------------------------
// Dot-strip geometry
// ---------------------------------------------------------------------------
export interface StripPoint {
/** 0100 percentage position along the track. */
pos: number;
labelAbove: boolean;
value: number;
schoolIndex: number;
}
/** Labels within 4% of the domain of a lower neighbour flip above the strip
* (the reviewed mockups' collision nudge). */
export function stripPositions(
values: Array<number | null>,
min = 0,
max = 100,
): StripPoint[] {
const span = max - min;
const points = values
.map((value, schoolIndex) => ({ value, schoolIndex }))
.filter((p): p is { value: number; schoolIndex: number } => p.value != null)
.map((p) => ({
value: p.value,
schoolIndex: p.schoolIndex,
pos: Math.min(100, Math.max(0, ((p.value - min) / span) * 100)),
labelAbove: false,
}));
const nudge = span * 0.04;
let lastBelow = -Infinity;
for (const p of [...points].sort((a, b) => a.value - b.value)) {
if (p.value - lastBelow < nudge) {
p.labelAbove = true;
} else {
lastBelow = p.value;
}
}
return points;
}
// ---------------------------------------------------------------------------
// Metric extraction
// ---------------------------------------------------------------------------
/** Latest non-null yearly value of `metricKey` per school, in `urns` order. */
export function latestValues(
data: Record<string, { yearly_data: Array<{ year: number }> }>,
urns: number[],
metricKey: string,
): Array<number | null> {
return urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
const v = (rows[i] as Record<string, unknown>)[metricKey];
if (typeof v === 'number' && !Number.isNaN(v)) return v;
}
return null;
});
}
+74
View File
@@ -99,6 +99,21 @@ export interface OfstedInspection {
rc_leadership_governance: number | null;
rc_early_years: number | null;
rc_sixth_form: number | null;
/** Where the effective overall grade came from: a graded (Section 5)
* inspection, or carried forward from an ungraded (Section 8) outcome. */
grade_source?: 'graded' | 'ungraded_carried_forward' | null;
/** Renewed-framework (Nov 2025) area judgements, coded + labelled by the
* backend from the live-sampled Ofsted vocabulary. Empty when the school
* has no report-card inspection. Safeguarding is never included here. */
report_card?: Record<string, ReportCardEntry>;
/** The school's page on ofsted.gov.uk (never a deep report link). */
ofsted_page_url?: string;
report_url?: string | null;
}
export interface ReportCardEntry {
code: number;
label: string;
}
export interface SchoolCensus {
@@ -129,6 +144,12 @@ export interface SchoolAdmissions {
/** 1st-preference applications per place offered (>1 means oversubscribed). */
oversubscription_ratio?: number | null;
oversubscribed: boolean | null;
total_offers?: number | null;
second_preference_offers?: number | null;
third_preference_offers?: number | null;
/** Applications naming this school from families in another LA, and offers to them. */
cross_la_applications?: number | null;
cross_la_offers?: number | null;
}
export interface SenDetail {
@@ -172,6 +193,22 @@ export interface SchoolResult {
school_id: number;
year: number;
// Progress confidence intervals + writing working-towards (published for
// years with progress measures, i.e. up to 2022/23)
reading_progress_lower_ci?: number | null;
reading_progress_upper_ci?: number | null;
writing_progress_lower_ci?: number | null;
writing_progress_upper_ci?: number | null;
writing_working_towards_pct?: number | null;
maths_progress_lower_ci?: number | null;
maths_progress_upper_ci?: number | null;
// KS4 banding and disadvantage gaps
/** DfE's own plain-English Progress 8 label, e.g. "Well above average". */
progress_8_banding?: string | null;
attainment_8_disadvantage_gap?: number | null;
progress_8_disadvantage_gap?: number | null;
// Pupil numbers
total_pupils: number | null;
eligible_pupils: number | null;
@@ -308,10 +345,47 @@ export interface SchoolDetailsResponse {
export interface ComparisonData {
school_info: School;
yearly_data: SchoolResult[];
// Supplementary blocks (additive; absent on an old backend)
ofsted?: OfstedInspection | null;
census?: SchoolCensus | null;
admissions?: SchoolAdmissions | null;
admissions_history?: SchoolAdmissions[];
deprivation?: SchoolDeprivation | null;
}
export interface BenchmarkBlock {
eal_pct: number | null;
sen_support_pct: number | null;
disadvantaged_pct: number | null;
median_pupils: number | null;
/** Primary only — weighted by cohort size. */
disadvantaged_rwm_expected_pct?: number | null;
}
/** Computed from our dataset — NOT official DfE figures. UI copy must say
* "state-school average (computed from our dataset)" (the `source` string). */
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>;
}>;
}
export interface ComparisonResponse {
comparison: Record<string, ComparisonData>;
national_averages?: NationalAverages;
benchmarks?: Benchmarks;
}
export interface RankingItem {
+18
View File
@@ -59,6 +59,24 @@ export function truncate(text: string, maxLength: number): string {
return text.slice(0, maxLength).trim() + '...';
}
/**
* A compact school label for tight spaces (mobile compare rows, chip bars):
* drop the trailing establishment-type words so "Barclay Primary School" →
* "Barclay", "St Mary's Catholic Primary School" → "St Mary's". Falls back to
* a length-capped truncation for names that don't carry a type suffix.
*/
export function shortName(name: string, maxLength = 32): string {
let s = name
.replace(
/\s+(primary|junior|infant|nursery|community|foundation|catholic|academy|school|college)\b.*$/i,
'',
)
.trim();
if (!s) s = name;
if (s.length > maxLength) s = s.slice(0, maxLength - 1).trim() + '…';
return s;
}
/**
* Format a school's age range for display, e.g. "3-11" → "Ages 311".
* Display-only — leaves the raw `age_range` field (used for sixth-form
@@ -160,6 +160,12 @@ models:
- name: year
tests: [not_null, unique]
- name: fact_ks4_national_averages
description: Computed national KS4 averages (means across state schools in our dataset — not official DfE figures) — one row per academic year
columns:
- name: year
tests: [not_null, unique]
- name: fact_deprivation
description: IDACI deprivation index — one row per URN
columns:
@@ -0,0 +1,25 @@
{{ config(materialized='table') }}
-- Mart: Computed national KS4 averages — one row per academic year.
-- Unlike fact_ks2_national_averages (official DfE figures), DfE publishes no
-- KS4 national-headline dataset we ingest yet, so these are means computed
-- across the state schools in our dataset. Computed once at build time so the
-- API never has to aggregate the full performance table per request.
-- Semantics match the API's previous per-request computation: rows where
-- attainment_8_score is non-null; per-column means ignore NULLs.
select
year,
round(avg(attainment_8_score)::numeric, 2) as attainment_8_score,
round(avg(progress_8_score)::numeric, 2) as progress_8_score,
round(avg(english_maths_standard_pass_pct)::numeric, 2) as english_maths_standard_pass_pct,
round(avg(english_maths_strong_pass_pct)::numeric, 2) as english_maths_strong_pass_pct,
round(avg(ebacc_entry_pct)::numeric, 2) as ebacc_entry_pct,
round(avg(ebacc_standard_pass_pct)::numeric, 2) as ebacc_standard_pass_pct,
round(avg(ebacc_strong_pass_pct)::numeric, 2) as ebacc_strong_pass_pct,
round(avg(ebacc_avg_score)::numeric, 2) as ebacc_avg_score,
round(avg(gcse_grade_91_pct)::numeric, 2) as gcse_grade_91_pct
from {{ ref('fact_ks4_performance') }}
where attainment_8_score is not null
group by year
order by year