Compare commits

...
Author SHA1 Message Date
TudorandClaude Fable 5 b44fca902f fix(api): fall back to legacy name-column query when marts predate code migration
Closes the deploy window flagged by CI review — the backend now works
against both the old (name) and new (code) mart schemas.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:02:26 +01:00
tudor 90f2a02e75 Merge pull request 'fix(school): make hero map fullscreen work on iOS Safari' (#10) from fix/hero-map-ios-fullscreen into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 52s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 34s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 8s
Reviewed-on: #10
2026-07-05 20:52:32 +00:00
TudorandClaude Fable 5 d52d384cf2 fix(school): make hero map fullscreen work on iOS Safari
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m15s
iOS Safari has no Element.requestFullscreen (fullscreen is video-only),
so tapping the map band or 'View on map' silently did nothing on
iPhones. Fall back to a fixed-position CSS overlay driven by state when
the Fullscreen API is missing or its promise rejects, locking body
scroll while open. The Leaflet map already re-measures via the shared
isFullscreen flag. New e2e journey simulates the iOS condition by
deleting the API and asserts the overlay opens and closes; it fails
against the current production build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:34:22 +01:00
tudor ff606dad71 Merge pull request 'fix(e2e): pick the latest explicit year in the rankings year test' (#8) from fix/e2e-rankings-year-pick into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 52s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 34s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 10s
Reviewed-on: #8
2026-07-05 13:54:55 +00:00
TudorandClaude Fable 5 acec8135e1 fix(e2e): pick the latest explicit year in the rankings year test
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m24s
Staging doesn't always carry the full data history, so selecting the
oldest year legitimately returns no rows and fails the promotion gate.
Select the most recent explicit year instead: the default view already
proved it has rows, so an empty table after selecting it can only mean
the year query param was rejected — the regression this test guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:29:43 +01:00
tudor 0a370e3b63 Merge pull request 'fix(api): accept academic-year codes in rankings year filter' (#7) from fix/rankings-year-validation into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 21s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 46s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m3s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #7
2026-07-05 10:46:04 +00:00
TudorandClaude Fable 5 6c872ce726 fix(api): accept academic-year codes in rankings year filter
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 52s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m44s
The rankings endpoint validated year with le=2100, but the database
stores academic-year codes like 201819, so any explicit year selection
returned a 422 and the rankings page rendered its empty state. Widen
the bound to cover the codes and extend the e2e journey to pick a
specific year and assert the table stays populated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:13:56 +01:00
tudor 23b4e1c453 Merge pull request 'fix(e2e): scroll detail-page chart into view before asserting' (#5) from fix/e2e-detail-chart-scroll into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 49s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 33s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 8s
2026-07-03 13:56:00 +00:00
TudorandClaude Fable 5 deeef23131 fix(e2e): assert on a visible canvas — the first canvas is hidden by design
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The admissions card stacks year/trend views in one grid cell and keeps the
inactive view visibility:hidden; its canvas is first in the DOM. Use
canvas:visible instead of scrolling. Verified: full suite passes against prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 14:40:13 +01:00
TudorandClaude Fable 5 4ece55b031 fix(e2e): scroll the detail-page chart into view before asserting visibility
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 55s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 34s
The canvas renders below the fold and stays 'hidden' to Playwright until
scrolled to; wait for attachment, scroll, then assert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 14:34:17 +01:00
tudor 515494dbf0 Merge pull request 'fix(analytics): restrict Umami to production hostnames' (#4) from fix/umami-prod-domains-only into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 50s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 0s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m7s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
2026-07-03 13:17:53 +00:00
TudorandClaude Fable 5 5772c54ccd fix(ci): use the documented GITHUB_TOKEN name for the run-scoped token
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m27s
GITEA_TOKEN worked (the review comment posted with it) but GITHUB_TOKEN is
the documented name in Gitea Actions; use it to keep the reviewer and the
docs aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 13:53:57 +01:00
TudorandClaude Fable 5 c62ba0ca25 fix(ci): post AI review comments with the run-scoped Gitea token
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 1m56s
REGISTRY_TOKEN lacks issue-write scope (403 on comment post). Gitea Actions
auto-provides a repo-scoped per-run token as secrets.GITEA_TOKEN — no
user-managed secret needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 13:39:59 +01:00
TudorandClaude Fable 5 b5a63e82d4 fix(analytics): restrict Umami to production hostnames
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 4s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 36s
The same frontend image runs on staging and prod (build once, promote), so a
build-time env var can't tell them apart. Umami's data-domains attribute
scopes the tracker client-side: events only fire when location.hostname is a
production domain, so staging traffic and the E2E journeys never pollute the
dashboards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 13:24:14 +01:00
tudor f5de745a8b Merge pull request 'fix(ci): AI review via Claude Code CLI; reuse REGISTRY_TOKEN' (#3) from fix/ai-review-claude-code into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 18s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 46s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Failing after 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Has been skipped
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
2026-07-03 08:50:26 +00:00
TudorandClaude Fable 5 d0895c71df fix(ci): AI review via Claude Code CLI; reuse REGISTRY_TOKEN for PR comments
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 27s
- ai_review.py now pipes the diff through headless Claude Code (claude -p,
  --output-format json) authenticated with CLAUDE_CODE_OAUTH_TOKEN from
  'claude setup-token' — subscription auth, no Anthropic API billing
- stdlib-only script (urllib instead of requests/anthropic)
- PR comments posted with the existing REGISTRY_TOKEN secret; the separate
  GITEA_TOKEN secret is no longer needed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 08:28:20 +01:00
tudor df0bf1c4d6 Merge pull request 'feat(sdlc): staging environment + automated staging→prod pipeline' (#2) from feat/sdlc-staging-pipeline into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 19s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 46s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 1m10s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
2026-07-03 06:17:40 +00:00
TudorandClaude Fable 5 4a52735356 feat(sdlc): staging environment + automated staging→prod pipeline
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 10m0s
PR Checks / Backend Smoke (pull_request) Successful in 48s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 24s
- pr-checks.yml: PR gate — frontend typecheck+jest, backend import smoke,
  image builds (no push), Claude AI review posted as PR comment (severe
  findings block merge)
- deploy.yml (replaces build-and-push.yml): merge to main builds+pushes
  images tagged sha-<sha>/staging, deploys the staging Portainer stack via
  webhook, runs Playwright E2E journeys against staging, then retags the
  verified images :prod (previous kept as :prod-previous) and deploys prod
- docker-compose.portainer.staging.yml: second Portainer stack — :staging
  images, sc_staging_* names, own macvlan IPs, Airflow on 8081; data
  bootstrapped from source via the staging Airflow DAGs
- prod compose now pins :prod instead of :latest (only the promotion step
  moves it; :latest is no longer published)
- e2e/: 6 Playwright journeys (search, postcode, detail, compare, rankings)
  driven by BASE_URL — the promotion gate
- scripts/ci/ai_review.py: Claude review with structured JSON findings
- docs/DEPLOY.md: full SDLC doc incl. one-time setup checklist and rollback
- replaced removed 'next lint' with tsc typecheck; fixed stale jest tests
  (slug URLs, N/A formatting, stable trend, fake-timer setup)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 06:50:40 +01:00
TudorandClaude Fable 5 f2ed49c0a1 feat(home): compact value-prop line on the mobile hero (P1.7)
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Phones showed only the poetic h1 and a bare search box — no coverage,
scope, or freshness statement above the fold for the 63%-of-entries,
56%-mobile audience. One compact line ('24,000+ English schools — SATs,
GCSEs, Ofsted & admissions, side by side. Updated for 2026/27') now
replaces the hidden eyebrow + full paragraph at ≤640px, costing ~2 short
lines. Desktop copy unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:56:21 +01:00
TudorandClaude Fable 5 f24b8044f8 fix(rankings): long metric labels wrap instead of widening the table
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 47s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Table auto-layout sizes columns by unwrapped header text, so labels like
'Reading, Writing & Maths Combined Higher %' pushed the value column —
and the table — past the viewport. The label now renders inside a block
span capped at 110px (84px on phones), forcing multiline and keeping the
table fully in view at any metric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:40:30 +01:00
TudorandClaude Fable 5 29f79fe948 feat(rankings,metrics): score visible on phones; definitions usable everywhere (P2.2, P2.3)
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Rankings mobile: the metric value — the point of the page — sat behind
a sideways swipe at 390px. The Area column now folds into a subline
under the school name, leaving Rank | School | Value to fit the
viewport with no horizontal scroll.

Rankings default metric becomes 'expected standard' (rwm_expected_pct);
'higher standard' stays available but no longer frames every school's
headline number in the terms parents least understand.

MetricTooltip was hover-only and display:none on phones — the mobile-
primary audience had zero access to the Attainment 8 / Progress 8 /
EBacc definitions. It is now a real button: tap/click/keyboard
toggleable with outside-click and Escape dismissal, 24px target,
shown at all viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:34:07 +01:00
TudorandClaude Fable 5 0294038fd3 fix(compare): shared ?urns= links win over the visitor's stored selection (P1.3)
The seed effect only adopted the URL's schools when localStorage was
empty, so a recipient who had ever used compare silently saw their own
old shortlist instead of the shared one. Explicit URL urns now replace
the stored selection on load (then persist as usual); bare /compare
still restores the visitor's own selection. Adds replaceSchools() to
the comparison context. Card values also switch to CHART_TEXT_COLORS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:34:07 +01:00
TudorandClaude Fable 5 a1fa4fe874 fix(a11y): WCAG AA colour contrast across every route (P0.1)
Live axe-core inventory found 13 failing fg/bg pairs (3.12:1 coral text/
fills, 4.0-4.5 teal near-misses, 2.7 gold badge, 1.9 chart-colour text).
All replacement values validated ≥4.5:1 against every background they
sit on:

- --accent-coral-dark deepened to #b04a2e and used for coral text roles
  (nav active, back/map links, kickers, chips) and coral fills under
  white text (btn-primary, phase tabs, section-nav compare, chart chips);
  new --accent-coral-darker #9c3f26 for their hovers. Decorative coral
  (pins, bars, borders, focus ring) keeps the brand #e07256.
- --accent-teal darkened to #296f6f (rankings values, metric rows,
  ofsted grades, eyebrows all pass).
- Ofsted green #3c8c3c→#2f7a2f; off-token gold #b8920e→--accent-gold-text.
- Gender-split pink text #b45778→#a04a68; admissions tip numerals to a
  3.8:1 muted brown (28px display text).
- New CHART_TEXT_COLORS: AA-dark counterparts of the Chart.js series
  palette for compare-card values (swatch dots keep true series colour).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:33:51 +01:00
TudorandClaude Fable 5 192173e515 feat(school-detail): icon-only Compare on phone heroes; unclutter map bottom edge
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
On ≤640px the floating Compare button becomes a 40px round glyph (+ / ✓,
aria-labelled) like the section-nav compare icon, instead of a full-width
text pill over the map. The OSM attribution moves to the map band's
top-left in preview so it no longer collides with the school name sliding
up under the fade; fullscreen keeps Leaflet's default bottom-right since
the zoom control occupies the top-left there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:17:12 +01:00
TudorandClaude Fable 5 921fe4212f fix(school-detail): float Compare over the map band, not the school name
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 17s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Regression from d4d9ae5 (map-blended hero): .headerHasMap .actions was
absolutely positioned intending to float over the map, but its containing
block was .headerContent — made position:relative in the same commit — so
top:14px anchored it to the title block below the map. On mobile the H1
spans the full width and the legacy '.actions { width: 100% }' rule still
applied, stretching the glassy button across the school name and leaving
a ~6px legible sliver between it and the map fade.

Anchor .actions to .header by moving position:relative/z-index:3 from
.headerContent to .titleSection (the element that actually needs to sit
above the fade), and set width:auto on the floating variant so the mobile
full-width rule can't reach it. Applied to both detail views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:54:49 +01:00
TudorandClaude Fable 5 f2b71b67d4 docs(audit): correct postcode finding — full postcodes do trigger proximity search
Recheck with 'B91 3DL' via the search box shows the geocoded radius
search works (distances, nearest-first, radius selector, map toggle).
Original P0.3 tested only a partial postcode, which silently falls back
to text search — refiled as P2.0 (silent mode switch on partial input).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:01:02 +01:00
TudorandClaude Fable 5 a059fa213e docs(audit): prioritized UX/UI audit report
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:41:44 +01:00
TudorandClaude Fable 5 1bd69e693a docs(audit): cross-cutting cohesion pass notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:28:09 +01:00
TudorandClaude Fable 5 c52169d5d8 docs(audit): journey 5 notes — admissions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:17:20 +01:00
TudorandClaude Fable 5 860f79e725 docs(audit): journey 4 notes — rankings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:11:37 +01:00
TudorandClaude Fable 5 3c02a0a478 docs(audit): journey 3 notes — building a comparison
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:49:39 +01:00
TudorandClaude Fable 5 fe0a7713df docs(audit): journey 2 notes — cold landing on school detail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:27:44 +01:00
TudorandClaude Fable 5 2dd1ff76ed docs(audit): journey 1 notes — home to school search
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:11:49 +01:00
TudorandClaude Fable 5 ee5b94099a chore(audit): axe harness and notes template for UX audit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:53:50 +01:00
TudorandClaude Fable 5 eeb3f2491a docs: execution plan for site-wide UX/UI audit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:14:34 +01:00
TudorandClaude Fable 5 683daa032e docs: design spec for site-wide UX/UI audit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:01:03 +01:00
TudorandClaude Opus 4.8 7e11129297 feat(school-rows): quiet chips for characteristics, uniform sizing
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 17s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Replace the faint middot separators on the characteristics line with
subtle borderless "quiet chips" so each attribute (type, age, faith,
gender) is its own scannable unit. Keep the coloured phase pill as the
one accent, and align every chip — including phase and provision tags —
to a single size/weight/radius (0.75rem / 600 / 4px).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:22:33 +01:00
TudorandClaude Opus 4.8 b7a94f1a52 feat(schools): label age range as "Ages 3–11" for clarity
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The bare "3-11" range gave no unit. Add a display-only formatAgeRange
helper ("3-11" -> "Ages 3–11", en-dash) used in the primary/secondary
search rows and the secondary detail badge. The raw age_range field is
left untouched so sixth-form detection (.includes('18')) still works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:05:05 +01:00
TudorandClaude Opus 4.8 58c5eb8ecc feat(admissions): reference SchoolCompare tools at each stage of the journey
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 47s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Add a per-step link on the primary and secondary admissions timelines
pointing to the relevant SchoolCompare tool (search, compare shortlist,
rankings) for that stage — from researching criteria through offer day
and appeals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:33:22 +01:00
TudorandClaude Opus 4.8 794c27f6b6 feat(footer): add contact email link
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
No contact route existed anywhere on the site. Add a
contact@schoolcompare.co.uk mailto link in the footer brand column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:30:04 +01:00
TudorandClaude Opus 4.8 cdb2e4cf41 fix(ofsted): link to the school's Ofsted page, not a hardcoded provider type
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The report link hardcoded provider type 21, which 404s for every school
whose Ofsted provider type isn't 21 (nurseries -> 20, academies -> 23, etc.).
Use Ofsted's URN-based find-inspection-report URL, which redirects to the
correct provider page for any school, and rename the link to "Ofsted reports"
since it points to the school's Ofsted page (all reports), not one report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:26:48 +01:00
TudorandClaude Opus 4.8 9e4cf9dfbf fix(ofsted): apply ungraded fallback on school detail page too
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 18s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The detail page reads overall_effectiveness from the API and showed
"Not rated" for ungraded-only schools, even though the list badge uses
the coalesced grade. Coalesce the ungraded fallback into the API's
overall_effectiveness so the detail page shows the same grade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:11:45 +01:00
TudorandClaude Opus 4.8 34fd4a6bcd fix(dbt): declare dim_school -> int_ofsted_latest dependency explicitly
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m10s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
int_ofsted_latest is only ref()'d inside a conditional block, so dbt
couldn't infer the edge and failed to compile dim_school. Add the
-- depends_on hint dbt recommends. No runtime behaviour change: the
adapter.get_relation guard still handles the pre-Ofsted-pipeline case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:53:11 +01:00
TudorandClaude Opus 4.8 2332ee6347 feat(ofsted): fall back to ungraded inspection outcome for school grade
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m30s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Ungraded (Section 8) inspections don't assign a fresh grade — the export
only gives free text like "School remains Good". Parse that text into a
grade (remains Outstanding -> 1, remains Good -> 2, else null) and use it
as a last-resort fallback when no graded overall effectiveness exists.

Also retain schools that have only an ungraded inspection (no graded date)
by coalescing the inspection date, so ~8.5k previously-dropped schools now
carry a grade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:44:01 +01:00
TudorandClaude Opus 4.8 eae62a4b42 fix(school-detail): make the hero map fade visible
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The blend gradient sat at z-index 2 — below Leaflet's tile pane (200) — so it
was painted behind the map and the band ended in a hard edge with no
diffusion. Raise the fade above the tile/overlay panes (450, still below the
marker pane so the pin stays crisp) and the whole-band open button above the
marker pane (800), and isolate the wrapper's stacking context so those raised
z-indexes don't leak out and outrank the header's Compare button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:26:30 +01:00
TudorandClaude Opus 4.8 d4d9ae5252 feat(school-detail): map-blended hero, remove at-a-glance stats
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Replace the header's at-a-glance stats row with a location map that sits
atop the hero and blends into the school title. The map is a static,
non-interactive preview (never traps page scroll) with a coral pin; the
whole band — or the inline "View on map ↗" link by the address — opens a
fullscreen, interactive map. Compare floats glassy over the band.

The separate "Location" section (and its nav item) is removed; the map now
lives only in the hero. Schools without lat/long render the header with no
map band, as before.

New: SchoolHeroMap (fullscreen wrapper, forwardRef open handle) +
LeafletHeroMapInner (minimal single-school map with interaction toggle).
Applied to both primary and secondary detail views; dead heroStats/tone/
mapContainer CSS removed (shared .heroStat* card classes kept).

Also drops the orphaned "Latest data" note and does not reintroduce an
Ofsted strip in the hero (it would duplicate the Ofsted section directly
below). Mockup kept at mockups/header-map-hero.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:12:12 +01:00
Tudor fba79b391a docs: note that the local server cannot be used for testing
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 16s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
2026-07-01 14:48:17 +01:00
TudorandClaude Opus 4.8 14474eccf1 feat(school-detail): move Back to a standalone link, dock "Back to top" in the nav
Replicate the Autotrader pattern: pull the page-back control out of the
sticky section-nav bar and place a standalone "← Back" text link above the
header card, on the page background, where it scrolls away with the page.
It keeps the context-aware behaviour (router.back → /search fallback); the
secondary view gains the same fallback for parity (it previously used a bare
router.back() that dead-ends on deep links).

The pinned slot the Back button vacated now holds a "↑ Top" control that
smooth-scrolls to the top, so the sticky bar keeps a useful affordance.
Applies to both primary and secondary detail views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:48:17 +01:00
TudorandClaude Opus 4.8 ce2bfea91b fix(search): badge inspected-but-ungraded schools as "Inspected", not "Not yet inspected"
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 54s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
A school inspected under the OEIF framework after September 2024 has an
inspection on record (ofsted_date set) but no overall_effectiveness grade,
since Ofsted no longer issues an overall judgement. buildOfstedListBadge
had no branch for this and fell through to "Not yet inspected", while the
detail page's hero chip correctly reported it as inspected — so the same
school read two contradictory ways.

Add an "Inspected · YYYY" branch that fires when an inspection is on record
(date or framework present) but no grade and not a Report Card, mirroring
the hero chip's fallback. Genuinely un-inspected schools (all Ofsted fields
null) still show "Not yet inspected". Add an .ofstedInspected badge style
(neutral slate) distinct from the grey pending state, and cover both cases
with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:36:23 +01:00
TudorandClaude Opus 4.8 b0639db79e feat(school-detail): dedupe hero header, drop redundant chip strip
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The hero header rendered the Ofsted signal twice — once as a chip in the
strip and again as a tile in the at-a-glance scorecard — and an
Oversubscribed chip already covered by the First-choice tile's footnote.

Remove the chip strip on both primary and secondary detail views, leaving
the scorecard trio (Results · Ofsted · First-choice) as the single home
for the headline numbers. Widen the scorecard's render gate to hasHeroStats
so a school with an Ofsted rating but no results still shows its Ofsted
signal (previously carried by the chip strip). Drop the now-dead .heroChip
CSS, keeping the shared .tone-* tokens the scorecard's serif number uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:45:49 +01:00
TudorandClaude Opus 4.8 207e3c631c fix(school-detail): rename admissions heading to "Admissions"
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
"How Hard to Get Into This School" presupposes difficulty, which misleads for the
many undersubscribed primaries (e.g. schools offering places to 100% of
first-choice applicants). Rename to the neutral "Admissions", matching the
secondary page and the section nav label; the demand figures and the
"Oversubscribed" chip convey the difficulty where it applies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
2026-07-01 11:15:54 +01:00
TudorandClaude Opus 4.8 23d7390286 feat(school-detail): reorder sections by parent demand (analytics-led)
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Reorders the detail-page sections so the most-sought content leads, based on
30-day section_nav_used analytics (nav clicks over-count buried sections, so a
low, high-demand section is a strong "surface me" signal):

Primary:  Ofsted → SATs Results → Admissions → Pupils & Inclusion → History →
          Phonics → What Parents Say → School Life → Location → Local Area → Finances
Secondary: Ofsted → GCSEs → Admissions → History → Parents → Wellbeing →
          Location → Finances

Key moves: Admissions (23%, most-clicked) lifted from #6→#3; Pupils & Inclusion
(18%) from #7→#4; History (13%) off the bottom. Ofsted stays first (its low click
rate is positional — everyone reaches it first). Low-demand context sections
(Local Area, Finances) stay last. Brings the primary page in line with secondary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
2026-07-01 09:51:46 +01:00
TudorandClaude Opus 4.8 51ba46c734 fix(school-detail): hide desktop nav controls on mobile (CSS order)
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The mobile breakpoint's display:none for .sectionNavAll / .sectionNavCompare was
declared before their base display rules, so equal-specificity cascade let the
base win and "All ▾" showed on mobile alongside the section menu. Move the
breakpoint swap block after the base declarations so the hide rules win.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
2026-07-01 06:38:21 +01:00
TudorandClaude Opus 4.8 2fb7faf659 feat(school-detail): mobile section nav becomes a menu; Compare shrinks to an icon
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Fixes the cramped mobile bar where Back, the carried Compare pill and All ▾ all
competed with the scrolling section links:

- On mobile the horizontal swipe strip collapses into a single "Section: <current> ▾"
  button that opens the existing jump-to-section sheet — no fragile horizontal
  swipe, and it doubles as a "you are here" indicator.
- The carried Compare CTA becomes a compact 38px icon on mobile (compare-arrows +
  "add" badge; flips to a teal check when in the comparison), so it no longer
  crowds the section control. Desktop keeps the labelled pill.
- Back is icon-only on mobile (label hidden), text on desktop.
- Desktop is unchanged: horizontal links + All ▾, full CTA in the hero.

Both the mobile section menu and the desktop All ▾ open the same sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
2026-06-30 23:27:03 +01:00
TudorandClaude Opus 4.8 758f902b66 feat(school-detail): dock section nav, pin Back, carry Compare CTA, add All ▾ menu
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 56s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Reworks the sticky section bar on the school detail page so mobile users can
perceive the page's breadth and get back to their results without hunting:

- Dock the bar directly under the global header (top: 56px mobile / 64px desktop)
  so it's reachable immediately instead of buried below the tall hero.
- Pin "← Back" so it never scrolls off; only the section links scroll, keeping
  the existing right-edge fade. Back uses router.back() with a /search fallback
  so deep-links never dead-end.
- Carry the hero's "Add to Compare" CTA into the bar as a compact pill once the
  hero button scrolls out of view, so the conversion action survives.
- Add an "All ▾" menu (dropdown on desktop, bottom sheet on mobile) listing every
  section with the active one ticked — full breadth without horizontal scrolling.

Reuses the site's existing pill / coral / edge-fade / sheet vocabulary rather
than introducing a new navigation paradigm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
2026-06-30 22:39:48 +01:00
TudorandClaude Opus 4.8 c40a6a949c feat(admissions): replace sparse Q&A list with 2×2 stat tiles
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 58s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m2s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m4s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
The "How Hard to Get Into This School" this-year view rendered four
question/answer rows stretched to fill the trend chart's height, leaving
tall gaps and a long horizontal jump from a small muted question to its
number. Replace with a 2×2 grid of stat tiles — each number paired with
its label directly beneath — so the panel reads quickly and fills the
reserved height without artificial spacing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:07:54 +01:00
TudorandClaude Opus 4.8 2de1c4e766 fix(admissions): stop 100% trend point being clipped at chart top
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
A first-choice offer rate sitting on the y-max ceiling (100%) was drawn
flush against the top of the plot area, clipping the point marker. Add 8px
top layout padding and clip:false so ceiling values render in full, without
introducing a misleading >100% tick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:32:09 +01:00
TudorandClaude Opus 4.8 81f80a01f4 Merge admissions trend Chart.js fix into main
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 54s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Brings the AdmissionsTrendChart (Chart.js) fix into main; the earlier PR
merged only the initial SVG version, which rendered with oversized,
overlapping labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:50:57 +01:00
TudorandClaude Opus 4.8 368b9f2b59 fix(admissions): render trend with Chart.js instead of scaled SVG
The hand-rolled SVG sparkline used px font sizes inside a 520-wide viewBox
that stretched to the full card width, so labels ballooned ~4x and collided —
and with 10+ years of real data the per-point labels and year ticks
overlapped badly, while the oversized chart stretched the "this year" view.

Replace it with a Chart.js line chart (AdmissionsTrendChart) in a fixed
200px wrapper, matching PerformanceChart: responsive px fonts, auto-skipping
x ticks, auto-scaled y-axis clamped to 0-100, emphasised latest point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:59:57 +01:00
tudor 8ad11f1728 Merge pull request 'feat(admissions): surface multi-year admissions trend on school detail' (#1) from feat/admissions-multi-year-trend into main
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Reviewed-on: #1
2026-06-19 17:42:20 +00:00
TudorandClaude Opus 4.8 4de7e559e9 feat(admissions): surface multi-year admissions trend on school detail
Build and Push Docker Images / Build Backend (FastAPI) (pull_request) Successful in 22s
Build and Push Docker Images / Build Frontend (Next.js) (pull_request) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (pull_request) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (pull_request) Has been skipped
The school detail page only showed the latest admissions year. We store
every year, which is more decision-relevant for parents (the trend and its
consistency matter more than a single noisy year).

Backend now returns the full admissions_history (oldest first) alongside the
existing latest-year object. The primary SchoolDetailView gains a header
toggle ("This year | N-year trend") that swaps the Q&A for an SVG sparkline
of the first-choice offer rate. The toggle only appears when >=2 years carry
an offer rate; otherwise it falls back to the single-year card. Both views
share one CSS-grid cell so switching causes no layout shift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:41:03 +01:00
Tudor e7c26a83db Updating homepage last update date
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 17s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
2026-06-18 14:20:17 +01:00
TudorandClaude Opus 4.8 3785f0c977 fix(pipeline): invoke dbt via python module to avoid Fusion shadowing
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 53s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 56s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m0s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The standalone dbt Fusion binary (dbt-core 2.x) on PATH shadows the
pip-installed classic dbt-postgres ~=1.10 and rejects the Postgres
adapter (dbt1005), breaking every DAG's dbt_build task. Invoke dbt via
`python -m dbt.cli.main` in the DAGs and the Dockerfile dbt deps step so
the classic Postgres-capable engine is always used regardless of PATH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:41:35 +01:00
Tudor SitaruandClaude Opus 4.7 87442788d4 fix(chart): name the metric in the primary trend summary
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The "Peaked at X%" hint didn't say which metric it referenced, leaving
parents to guess. Both branches now lead with "Reading, Writing & Maths".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:16:31 +01:00
Tudor SitaruandClaude Opus 4.7 62eeee5f7c perf: cache aggressively and trim client bundle
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m1s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m4s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Frontend
- Dynamic-import Chart.js components on detail/compare views so Chart.js
  no longer ships in initial JS.
- Drop force-dynamic on home, compare, rankings so internal data fetches
  reuse Next.js's per-call revalidate cache.
- Switch /school/[slug] to ISR with a 7-day revalidate window (school
  data updates annually).
- Preconnect to analytics + postcodes.io; remove redundant defer on the
  Umami Script tag (afterInteractive already covers it).
- Bump images.minimumCacheTTL to 1 year.
- Extract HowItWorks and Editorial sections as server components passed
  to HomeView via slot props so their JSX stays out of the client bundle.

Backend
- Add GZipMiddleware (min 512 bytes).
- Add CacheAndETagMiddleware: per-path Cache-Control with long s-maxage
  + stale-while-revalidate, ETag generation, and 304 on If-None-Match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:46:45 +01:00
Tudor SitaruandClaude Opus 4.7 a7ab624a01 feat(analytics): enable Umami's built-in Web Vitals collection
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Adds data-performance="true" to the Umami script tag. From v3.1.0+,
this hooks PerformanceObserver and posts LCP, INP, CLS, FCP and TTFB
to the collect endpoint with Google's rating buckets pre-applied.
The Umami dashboard then surfaces them in its built-in Performance
tab with p50/p75/p95 percentiles and per-page / per-device breakdowns
— no custom event taxonomy needed.

Requires the analytics instance to be on Umami ≥ v3.1.0. The
attribute is a no-op on older versions, so no risk to ship before
upgrading the dashboard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:56:33 +01:00
Tudor SitaruandClaude Opus 4.7 7e182e88b2 feat(analytics): typed Umami event taxonomy across the funnel
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Add lib/analytics.ts with a single typed track() wrapper. SSR-safe,
never throws, no-ops when Umami isn't loaded. Event names form a
fixed union so refactors stay safe.

14 events wired:

  Discovery (3)
    search_submitted          FilterBar submit + near_me path
    near_me_used              all geolocation outcomes
    empty_results             search returns 0 schools

  Engagement (5)
    school_viewed             SchoolDetail + Secondary on mount, with
                              urn / phase / local_authority / from
    section_nav_used          section-nav links on both detail views
    chart_metric_changed      mobile chart chip switch
    metric_compared_in_rankings rankings metric dropdown
    external_link_clicked     Ofsted / school website / DfE (declarative
                              data-umami-event attributes)

  Conversion (5)
    compare_school_added      search/rankings/detail/compare sources
    compare_school_removed    detail toggle and compare page
    compare_viewed            once per session when there's a selection
                              (school_count, phase_mix)
    compare_metric_changed    compare page metric dropdown
    compare_shared            native sheet vs clipboard distinguished

  Operational (1)
    api_error                 caught in handleResponse, includes
                              endpoint / status / route

Suggested Goals to configure in the Umami dashboard for the funnel
report: search_submitted → school_viewed → compare_school_added →
compare_viewed → compare_shared.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:04:22 +01:00
Tudor SitaruandClaude Opus 4.7 4cfae93a0d fix(chart): collapse empty space below desktop chart
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 54s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
.chartWrapper had height:100% but its parent .chartOuter (a flex
column) had no explicit height — so the canvas couldn't resolve a
real height and fell back to Chart.js's small default, leaving a
big empty band between the plot and the "View raw year-by-year data"
disclosure on desktop.

Give .chartOuter height:100% so it fills the 280px .chartContainer,
and switch .chartWrapper to flex:1 1 auto / min-height:0 so the
canvas fills whatever space remains after the (primary-only) trend
banner. Mobile's explicit .chartWrapper height:220px still wins
inside the responsive override.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 14:10:16 +01:00
Tudor SitaruandClaude Opus 4.7 99dc5e7f8b feat(rankings): default primary metric to higher standard, not expected
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 56s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The expected-standard ranking groups too many schools at or near
100% — the leaderboard isn't useful when the top 30+ schools all
share the same value. Switch the default primary metric to the
higher (above-expected) standard, which discriminates between
schools much more clearly at the top end. Secondary still defaults
to Attainment 8. Users can still pick either via the dropdown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:46:53 +01:00
Tudor SitaruandClaude Opus 4.7 763aef09f8 fix(chart): stop "View raw data" link overlapping the mobile chart canvas
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The previous commit added the chip strip and subtitle inside the
.chartOuter, but .chartContainer on the parent SchoolDetailView still
had a fixed height: 220px. With the new content stacking above the
canvas the chart + COVID footnote overflowed past 220px, and the
<details> "View raw year-by-year data" disclosure (rendered just
after the container) landed on top of the plot. It also pushed the
COVID footnote out of the card onto the page background.

- .chartContainer at ≤640px now flows naturally (height: auto)
- .chartWrapper at ≤640px gets an explicit 220px height so the canvas
  itself still has a known size for Chart.js to render into.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:40:11 +01:00
Tudor SitaruandClaude Opus 4.7 d569a2afda feat(chart): mobile-only single-metric chip selector for Results Over Time
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The Results Over Time chart on the school detail page was overcrowded
on phones — 5-item legend wrapping over the plot area, a hidden right
y-axis still rendered for the (collapsed) progress series, fixed
0–100 percentage scale that flattened all variation, angled x-axis
labels eating vertical space.

At ≤640px the chart now renders one metric at a time, selected via a
chip strip above the plot. No more legend. No more dual y-axis. The
y-axis auto-tightens around the actual data range so variation is
visible (a school sitting in the 70–90% band now uses a 65–95 axis
instead of squashing onto a 0–100 line). A small subtitle above the
chips sets the subject context ("KS2 SATs · Reading, Writing & Maths"
or "GCSE results · Year 11") so chip labels can describe the *view*
rather than re-stating the subject.

Chip labels are spelled out in parent-facing language — no internal
shorthand like "RWM":

  Primary  (KS2):  At expected level (default)
                   Above expected level
                   Pupil progress         (shows 3 series + mini-legend)

  Secondary (KS4): Attainment 8 (default)
                   English & Maths grade 4+
                   Progress 8

Chips disable themselves (greyed, with a "No data for this school"
title) when the underlying series has no data points. Desktop
behaviour is unchanged — the full multi-series chart with dual y-axis
still renders >640px.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:49:19 +01:00
Tudor SitaruandClaude Opus 4.7 1ca957499a feat(mobile): promote filter toggle when active + document 360px baseline
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 47s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
MOB-08: Search list pagination is already implemented (page_size 50,
"Load more schools" button + count) — no change needed; ticket closed.

MOB-10: Rather than build a full bottom-sheet filter modal (large
change, modal/focus-trap/scroll-lock infra), promote the existing
"Advanced" toggle to a coral pill labeled "Filters (n)" whenever
dropdown filters are applied. Users now see at a glance that the list
is being narrowed; the inline accordion remains the disclosure
mechanism. Adds aria-expanded for screen readers.

MOB-23: Add MOBILE.md at the repo root with the 360 px design baseline,
acceptance checks for any UI PR (no horizontal overflow, ≥44px tap
targets, no <11px visible text, iOS Chrome parity, safe-area-inset,
dvh), and the established component patterns. Playwright regression
test deferred — adding the dep for one test is heavier than the
current value warrants; documented as a future option.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:52:17 +01:00
Tudor SitaruandClaude Opus 4.7 9133ecdcd4 feat(mobile): iOS polish — theme-color, safe-area, dvh, tap-highlight
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
MOB-19: Add a viewport Viewport export with viewportFit: 'cover' and
themeColor entries for light (#faf7f2) / dark (#1a1612), plus the
appleWebApp metadata for the home-screen status bar style and title.
Manifest's stale #3b82f6 theme_color updated to match brand cream.

MOB-20: Apply env(safe-area-inset-*) to the sticky chrome — the top
header gets max(padding, inset-left/right) so the logo and tab links
clear the notch in landscape; the bottom tab bar already had
inset-bottom and now also gets inset-left/right.

MOB-21: Replace 100vh with 100dvh in body min-height, modal max-heights,
the map view container, and the fullscreen map. Older engines fall
back via the duplicated vh declaration.

MOB-22: Set -webkit-tap-highlight-color: transparent on body to
suppress the iOS Safari grey flash; add a generic touch-pointer
:active rule (opacity 0.7) so taps still register visually on plain
anchors and bare buttons. Components with their own :active styling
are unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:47:13 +01:00
Tudor SitaruandClaude Opus 4.7 56ab1368b1 feat(mobile): compare table scroll fade and native share sheet
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
MOB-14: PerformanceChart and ComparisonChart both already configure
legend position 'top' — no change needed.

MOB-15: Compare's detailedTable is 774px wide inside an overflow-x:
auto wrapper. Add a right-edge mask-image fade at ≤640px so phone
users see the table extends past the viewport.

MOB-16: ComparisonView's Share button previously did clipboard-only.
Prefer navigator.share when available (iOS/Android native sheet) so
users can send straight to Messages / WhatsApp / Mail / etc. Fall
back to clipboard with the existing "Copied!" toast otherwise. User
cancellations swallow silently; only real errors trigger fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:40:23 +01:00
Tudor SitaruandClaude Opus 4.7 59f13a74f9 feat(mobile): mobile cleanups for deadlines, result cards, tooltips, ofsted, rankings
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
MOB-07: Admissions deadlines strip becomes a horizontal snap-scroller
on ≤640px instead of a cramped 2×2 grid (which forced "Secondary ·
Deadline" track labels down to 9.6px). Cards stay readable, the right
edge fades to signal more content past the viewport, .chipTrack font
bumped to 0.7rem.

MOB-09: Result-card line3 (headline metric + secondary stats) was
crowding everything onto one row. Force the first .stat (Attainment
8 / RWM headline) to flex-basis 100% on mobile so delta-vs-LA and
pupil count wrap below it with a visible row-gap. Applied to both
SchoolRow (primary) and SecondarySchoolRow.

MOB-12: MetricTooltip ⓘ icons rendered at ~9px and relied on :hover
(which doesn't fire on touch). Hide the whole .wrapper at ≤640px —
metric labels themselves carry the meaning. Saves building a
tap-to-show layer for now.

MOB-13: The "Ofsted pending / No inspection on record" empty state
took a full hero card to communicate non-information. Add a
data-ofsted-state attribute on the hero chip; on ≤640px, the
"none" state collapses to a single muted line.

MOB-17: Already had Type+Action columns hidden on rankings mobile —
no change needed beyond marking complete.

MOB-18: Long metric headers ("Reading, Writing & Maths Combined %")
forced the value column wide. Drop .valueHeader to 0.625rem with
white-space: normal at ≤640px so labels wrap onto 2 short lines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:38:48 +01:00
Tudor SitaruandClaude Opus 4.7 38d033f6a9 fix(rankings): expose selected metric under stable value key
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 22s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
The /api/rankings endpoint returned each row keyed by the metric's
column name (e.g. rwm_high_pct) but never under a generic `value`
field. The frontend RankingItem type and RankingsView both read
ranking.value, so every row rendered "—" for every metric — the
default rwm_expected_pct included.

Add `df["value"] = df[metric]` before JSON serialisation so the
frontend gets the value it has always expected. The raw metric
column is still in the row for any caller that wants it explicitly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:31:15 +01:00
Tudor SitaruandClaude Opus 4.7 6045114ca2 feat(mobile): trim home hero and school-detail hero above the fold
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 17s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 56s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
MOB-06: On phones the home hero stacked eyebrow tag, h1, description
paragraph, search input, button, "Schools near me" and three explore
chips before the user could see the deadlines strip. Hide the eyebrow
and the descriptive paragraph at ≤640px (the h1 already names the
product; the search input is the primary action) and move the
"Start exploring" chips to render after the admissions deadlines —
time-sensitive info now leads, generic discovery follows. Result on
390×844: heading → search → Schools near me → first deadline chip
all fit above the fold.

MOB-11: The school-detail hero took ~2 viewports before the first real
metric. At ≤768px, switch .meta back to row+wrap so the short pills
("Manchester" / "Voluntary aided") flow 2-per-row instead of stacking
3 full rows, and hide the .headerDetails block (headteacher / website /
pupil count / trust) — secondary info that lives in the Pupils &
Inclusion section anyway. Reclaims ~70px of hero so the Ofsted card
and the headline metric surface within a single viewport.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:20:35 +01:00
Tudor SitaruandClaude Opus 4.7 e39a79bab0 feat(mobile): hide ComparisonToast — Compare tab badge replaces it
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
MOB-05: With the bottom tab bar's Compare badge now showing the
selected count and providing one-tap navigation to /compare, the
floating toast becomes redundant chrome on phones — it cost ~70px
of permanent vertical space and visually competed with the tab bar
right above it. Hide at ≤640px. Per-school removal still works on
the /compare page itself. Desktop unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:47:02 +01:00
Tudor SitaruandClaude Opus 4.7 a5be07ac0f fix(nav): keep bottom tab bar flush to visible viewport on iOS Chrome
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
iOS Chrome (and some Android browsers) auto-hide their URL bar on
scroll. This grows the visual viewport without changing the layout
viewport, so a position:fixed bar pinned to bottom:0 — which is
relative to the layout viewport — appears to float mid-screen with
a gap beneath it. Safari masks the bug because its toolbar shrinks
rather than fully retracting.

Track the delta between the visual and layout viewports via the
VisualViewport API and write it to a --mobile-bar-offset CSS var.
The bar uses translate3d to apply that offset, which both fixes the
gap and enables hardware compositing so it tracks the toolbar
animation without flicker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:38:28 +01:00
Tudor SitaruandClaude Opus 4.7 4acfd21883 feat(mobile): section-nav scroll affordance and drop illegible home previews
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
MOB-03: The school-detail section nav (SATs / Admissions / Pupils /
Location / History) overflows on phones (scrollWidth 462 vs clientWidth
356) with no signal that more tabs exist past the right edge. Add a
right-edge mask-image fade at ≤640px, scroll-snap on each link, and
bigger tap targets (min-height 36px, padding bumped). A scroll/resize
listener toggles an .atEnd class that removes the fade once the user
has scrolled to the last tab.

MOB-04: The "What you'll see on every school" cards rendered preview
visuals (mini cascade chart, Ofsted badge, compare table) with text
scaled down to 6–10px — unreadable on a phone. Hide .hiwVisual at
≤640px and let the explanatory text carry each card; tiny visible
text count on the home dropped from 51 to 8.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:35:17 +01:00
Tudor SitaruandClaude Opus 4.7 2a8ff29ccd feat(nav): mobile bottom tab bar and ≥44px logo tap target
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 49s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 56s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m6s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
MOB-01: At ≤640px the top inline nav is hidden and replaced by a
fixed bottom tab bar (Search / Compare / Rankings / Admissions) with
icon + label, 56px tap targets, env(safe-area-inset-bottom) padding,
and the Compare count rendered as a badge on the icon. Eliminates
horizontal page overflow on every route (docW was 401–429 at vw 390;
now docW === vw).

MOB-02: Logo link gains a padded hit area so the touch target is
≥44×44 (was 36×36), without resizing the visual mark.

ComparisonToast lifted above the new bottom bar on mobile so the two
do not stack on top of each other. body gets a bottom padding equal
to the bar height + safe-area inset so page content is never hidden.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:26:01 +01:00
Tudor SitaruandClaude Opus 4.7 976ebe752b copy(home): simplify hero eyebrow to "Updated with 2024/25 results"
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 2m22s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 54s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 10:52:43 +01:00
Tudor SitaruandClaude Opus 4.7 1fb4b3ec5e refactor(primary): move gender split out of header into Pupils & Inclusion
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Header "Pupils" line reverts to plain inline text matching Headteacher
and website weight. Gender split lives in Pupils & Inclusion as a card
alongside pupil premium / EAL / SEN support — peers of similar weight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 22:52:59 +01:00
Tudor SitaruandClaude Opus 4.7 675601869b feat(detail): show pupil gender split on school detail pages
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 19s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Upgrades the existing "Pupils" stat to include a compact split bar and
percentage hint for mixed schools (single-sex schools already carry a
"Boys's/Girls's school" badge, so the split would be redundant).

Wires fact_pupil_characteristics into the API: new SQLAlchemy model and
a real census block in /api/schools/{urn} replacing the prior null stub.

On the primary detail page the inline "Pupils: 241" text is replaced by
a richer block (display number + bar + "52% girls · 48% boys"). On the
secondary detail page the existing "Total pupils" hero stat card grows
the bar and hint beneath the number. Both fall back to the previous
text-only rendering when census gender data is missing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 22:36:33 +01:00
Tudor SitaruandClaude Opus 4.7 b7da3054e1 feat(admissions): add sticky left-rail in-page nav, primary first
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Rail pins alongside the content with scroll-spy highlighting the current
section (Primary, Secondary, Tips). Collapses to a sticky top pill bar
on narrow screens. Primary section now precedes Secondary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 20:50:00 +01:00
Tudor SitaruandClaude Sonnet 4.6 c39256b1a0 feat(home): sort countdown chips by days remaining, fade in after hydration
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Chips now sort soonest-first at hydration time so the most urgent
deadline always appears first. The rail is hidden (opacity 0) until
the useEffect populates and sorts the chips, then fades in — avoiding
any visible layout shift from the reorder.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 16:06:21 +01:00
Tudor SitaruandClaude Sonnet 4.6 9e0b004d93 copy: update homepage tagline to "Every school in England, compared."
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:09:53 +01:00
Tudor SitaruandClaude Sonnet 4.6 795e2bae35 fix(ui): countdown shows Today correctly — use < not <= in daysUntil
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 20s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
<= caused today's date to roll forward to next year (returning 365).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 12:28:31 +01:00
Tudor SitaruandClaude Sonnet 4.6 822d2afba1 feat(ui): show "Today" on countdown chips when milestone is today
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 47s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 12:02:14 +01:00
Tudor Sitaru 9d34459191 fixing nav on secondary schools
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 56s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
2026-04-16 11:46:32 +01:00
Tudor SitaruandClaude Sonnet 4.6 e52467ff5d fix(pipeline): add stg_legacy_ks4+ to annual EES dbt build select
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 16s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m3s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
int_ks4_with_lineage references stg_legacy_ks4 but the model was never
selected for build, causing a missing relation error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 11:10:09 +01:00
Tudor SitaruandClaude Sonnet 4.6 ae33bfe04b refactor(pipeline): unify KS2 and KS4 legacy sources to same annual ZIPs
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 47s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m18s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
LegacyKS2Stream now auto-detects ZIP vs bare CSV — if the download is a ZIP
it extracts england_ks2final.csv; if it's a plain CSV file it reads directly.
This keeps backwards compatibility while allowing both streams to share the
same DfE annual archive URLs.

legacy_ks2_urls updated to point at the same 4 ZIPs as legacy_ks4_urls so
only one set of archives needs to be maintained going forward.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:41:01 +01:00
Tudor SitaruandClaude Sonnet 4.6 785cb72063 config(pipeline): add legacy_ks4_urls for 2015/16–2018/19
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 20s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Frontend (Next.js) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:39:31 +01:00
Tudor SitaruandClaude Sonnet 4.6 7e6ded29e2 feat(pipeline): add legacy KS4 backfill (2015/16–2018/19)
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
Mirrors the existing legacy KS2 pattern to fill the gap before EES hosted
KS4 data. Four files changed:

- tap-uk-ees: LegacyKS4Stream downloads each year's DfE Compare School
  Performance ZIP, extracts england_ks4final.csv, maps 416 legacy columns
  to Singer fields, strips % suffixes. Registered in discover_streams().
  TapUKEES.config_jsonschema gains legacy_ks4_urls setting.

- stg_legacy_ks4.sql: safe_numeric casts + NULL placeholders for columns
  not present in legacy format (ebacc_avg_score, gcse_grade_91_pct,
  prior_attainment_avg, sen_pct).

- int_ks4_with_lineage.sql: adds all_ks4 CTE unioning stg_ees_ks4 and
  stg_legacy_ks4, matching the int_ks2_with_lineage pattern.

- _stg_sources.yml + meltano.yml: source declaration and setting definition
  for legacy_ks4. URLs configured per-year once provided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:37:24 +01:00
Tudor SitaruandClaude Sonnet 4.6 3401654ab9 fix(pipeline): restore multi-year KS4 data
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 17s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m21s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Two bugs prevented historical secondary school data from loading:

1. stg_ees_ks4.sql filtered breakdown_topic = 'Total' only, but EES
   releases prior to 2023/24 use breakdown_topic = 'All pupils' (matching
   the KS2 convention). All older years were silently dropped to zero rows.
   Fix: accept both values with an IN clause.

2. get_all_releases() in tap-uk-ees fetched only the first page of the
   EES releases API. Now follows all pages via the paging.totalPages field
   so no historical release is missed when more than 20 exist.

After re-running the annual EES pipeline, secondary school comparison
charts should show data across all available years (2018/19 onwards).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:18:55 +01:00
Tudor SitaruandClaude Sonnet 4.6 8154a59014 fix(compare): prevent auto-phase from overriding manual tab selection
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Once the user explicitly clicks a phase tab, suppress auto-phase detection
so switching to Secondary (or Primary) can't be snapped back by the effect
that fires when comparisonData re-fetches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:11:47 +01:00
Tudor SitaruandClaude Sonnet 4.6 2e3456b21b fix(compare): auto-phase tab now also syncs metric to match detected phase
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The useEffect that auto-selects the Primary/Secondary tab was calling
setComparePhase() without updating selectedMetric. When a URL carries a
primary metric (e.g. metric=rwm_expected_pct) but the shortlisted schools
are secondary, the tab would switch to Secondary while the metric stayed
at rwm_expected_pct — which is null for all secondary schools, causing
every card to show "–" and requiring a manual tab toggle to fix.

Fix: after determining the phase from school data, check whether the
current metric belongs to that phase's category list. If not, reset to
the phase default (attainment_8_score for secondary, rwm_expected_pct
for primary). A metric that already fits the phase (e.g. the URL already
carried attainment_8_score) is preserved unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:57:02 +01:00
Tudor SitaruandClaude Sonnet 4.6 f05bbba613 perf: resolve all P1–P5 performance issues from code review
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 21s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
P1 (backend/data_loader.py): Add load_latest_school_data() which pre-computes
the one-row-per-school latest-year snapshot (groupby, prev-year trend merge)
once at startup instead of on every /api/schools request. get_schools route
now starts from the cached snapshot rather than rebuilding it.

S3 (backend/app.py): Wrap synchronous geocode_single_postcode() call in
asyncio.to_thread() so postcode lookups no longer block the uvicorn event
loop. Admin reload endpoint also uses to_thread for both cache primes.

P2 (nextjs-app/components/HomeView.tsx): Add mapParamsRef guard so switching
back to map view does not re-fetch 500 schools when search params haven't
changed. Reset ref on new searches so fresh data is always fetched.

P3 (nextjs-app/lib/chartSetup.ts): Extract Chart.js registration into a
shared side-effect module. ComparisonChart and PerformanceChart now import
it instead of each calling ChartJS.register() independently.

P4 (backend/database.py): Remove unnecessary db.commit() from the read-only
get_db_session() context manager — saves a DB round-trip on every request.

P5 (backend/database.py): Add pool_recycle=1800 to SQLAlchemy engine to
prevent stale TCP connections from accumulating in long-running processes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:45:46 +01:00
Tudor SitaruandClaude Sonnet 4.6 f6b9d650f8 feat(admissions): add admissions guide page and homepage countdown strip
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- New /admissions route with AdmissionsView client component
- Live countdowns (days until) to Primary/Secondary deadlines and Offer Days
- Step-by-step timelines for both tracks with highlighted milestone rows
- Tips section covering equal preference rule, late applications, waiting lists
- Homepage countdown strip (4 cards) between discovery chips and how-it-works
- Admissions nav link and footer link added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 17:00:21 +01:00
Tudor SitaruandClaude Opus 4.6 3327728df0 feat(secondary): apply primary visual design to secondary school detail
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 2m17s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
- GCSE top metrics (Att8, P8, Eng+Maths 4+/5+) promoted to heroStatCard
  teal-tinted cards with Playfair serif values and DeltaChip vs national
- Attainment 8 visual bar: 0–80 scale with coral national-average marker
  and pill label, mirrors the SatsChart concept for a score metric
- Progress 8 number line: −3 to +3 axis showing CI band, zero baseline,
  and a teal/coral dot for the school's score (hidden when P8 suspended)
- SEN section upgraded from plain metricCard to heroStatCard grid
- History table moved into a details/summary accordion (collapsed by
  default); PerformanceChart now lives at the top of the History section
  always visible above the disclosure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:21:21 +01:00
Tudor SitaruandClaude Opus 4.6 ac2d64caaf feat(home): implement redesigned homepage
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Hero: Playfair heading with coral italic accent, teal eyebrow pill,
  richer sub-copy describing both primary and secondary coverage
- Discovery: geolocation "Schools near me" button (reverse-geocodes via
  postcodes.io → /?postcode=…&radius=1), plus Start exploring chips
  linking to /rankings and /compare
- How it works: 3-card grid showing miniature real-UI previews for
  Performance (primary SATs cascade + secondary Att8 bar), Ofsted
  inspection card, and side-by-side Compare table
- Editorial: text column + factbox (totalSchools, LA count, coverage
  dates) rendered inside a white card below the how-it-works section
- Footer: expanded to 3 columns (brand blurb, Product, Resources);
  links updated to / /rankings /compare and real gov.uk/ofsted URLs
- All new sections visible only on landing (no search active)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 21:02:18 +01:00
Tudor SitaruandClaude Opus 4.6 bfff24fa5f style(detail): apply hero card style to Pupils & Inclusion
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Renames the RWM-specific .rwmHero* classes to generic .heroStat*
and reuses them on the three Pupils & Inclusion headline cards
(disadvantaged, EAL, SEN support). Grid switches to auto-fit to
accommodate both 2- and 3-card layouts. SEN primary-need breakdown
stays on the dense .metricCard style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 14:31:31 +01:00
Tudor SitaruandClaude Opus 4.6 34cd8ad26e style(sats): restyle RWM hero cards to match approved mockup
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Top two combined cards now use a teal-tinted background with a
2.1rem Playfair serif teal value and a compact uppercase label —
consistent with the hero treatment shown in the design mockup.
Scoped via new .rwmHero* classes so other metric cards on the page
keep their existing style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 14:01:45 +01:00
Tudor SitaruandClaude Opus 4.6 a27b9abd9f style(sats): tighten cascade bars to match approved mockup
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Bars drop from 22px to 14px with softer 4px radius, percentages render
in Playfair serif to match the rest of the detail page typography,
national-average line opacity bumped from 25% to 35% for legibility.
Subject name and ruler proportions also aligned with the mockup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 13:52:00 +01:00
Tudor SitaruandClaude Opus 4.6 045dbc65b7 feat(sats): add "why is combined lower?" bridge between hero and cascade
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 2m20s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Explains the intersection semantics of RWM combined — a pupil is only
counted if they met the bar in all three subjects — with a math line
showing the per-subject percentages collapsing to the combined figure.
Only renders when all four values are present; national-average pill
markers on the cascade are untouched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 13:30:11 +01:00
Tudor SitaruandClaude Opus 4.6 35deedcc16 feat(admissions): promote verdict to headline, move above Q&A
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
The Oversubscribed badge + "Demand exceeds capacity" text lived at
the bottom of the tile as an afterthought. It's the headline finding —
a parent should read it first, then let the Q&A supply the detail.

Replace the footer badge with a Playfair Display sentence directly
under the section title: "This school is oversubscribed." (state word
coloured coral for oversubscribed, teal for undersubscribed). The
"Demand exceeds capacity" / "Supply meets demand" line sits below as
quiet muted text — present for context but not competing with the
headline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 11:03:57 +01:00
Tudor SitaruandClaude Opus 4.6 5abab067a1 feat(admissions): replace bar + metric cards with Q&A tile
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m4s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The "How Hard to Get Into This School" tile mixed a progress bar
(places vs first-choice) with three text metric cards, making the
data feel fragmented and hiding the real narrative. The progress bar
also broke visually when undersubscribed and didn't scale to different
school sizes.

Replace with a typographic Q&A list that answers the questions
parents actually ask — "How many places were offered?", "How many
families wanted this school first?", "How many got their first
choice?", "How many applied in total?" — with a verdict footer
(Oversubscribed / Not oversubscribed + one-sentence explanation).

The third row now uses first_preference_offers (already in the API
response) to show "27 of 42 (64.3%)" instead of just the percentage,
giving the raw count parents actually want.

Each row is independently null-gated; rows stack vertically under
480px so the Playfair numeral stays legible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 10:01:19 +01:00
Tudor SitaruandClaude Opus 4.6 6d685b7e8a refactor(admissions): rename published_admission_number to places_offered
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Failing after 13s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
The staging model aliased EES's total_number_places_offered column as
published_admission_number, but PAN is the school's published capacity
(not exposed by EES at school level) — what we actually have is the
count of places offered in a given admissions round. The misnomer
propagated to the mart, SQLAlchemy model, API response, TS types, and
UI copy ("places per year", "(PAN)").

Rename end-to-end and fix the UI labels:
  - "29 places for 42 first-choice applications"
      → "29 places offered for 42 first-choice applications"
  - "Reception/Year 7 places per year"
      → "Reception/Year 7 places offered"
  - drop the misleading "(PAN)" suffix in the secondary view

Also add a comment in stg_ees_admissions clarifying this is the number
of places offered, not PAN. Requires dbt to rebuild fact_admissions
(marts are materialized as tables) before the backend can start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 09:45:43 +01:00
Tudor SitaruandClaude Opus 4.6 24ba65c829 fix(detail): scale SATs cascade bars against full chart width
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Labels were sharing a flex row with the bar, so the bar's width %
was computed against the flex remainder after the label rather than
the full chart area. A 96% bar rendered around the 75% ruler mark,
and mobile was worse. Move labels to a header row above each bar and
give the bar a full-width track, so X% now aligns with X% on the ruler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 08:40:38 +01:00
Tudor SitaruandClaude Opus 4.6 3bf2e8f262 feat(detail): replace SATs text tables with cascade bar charts, add admissions bar and history accordion
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 19s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Redesign the School Details page for better parent comprehension:
- New SatsChart component: horizontal cascade bars with ruler scale and
  national average marker (teal/coral palette matching site theme)
- Admissions section: visual progress bar showing 1st-preference demand
  vs available places, colour-coded by oversubscription status
- Historical data: collapse raw year-by-year table behind a disclosure
  element while keeping the performance line chart always visible
- EAL metric: add national average comparison via DeltaChip (backend now
  includes eal_pct in national averages endpoint)
- New formatWithSuppression utility for null/suppressed data handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:22:24 +01:00
Tudor Sitaru 8ce34b3ecc fix(list): read ofsted grade from fact_ofsted_inspection directly, fix dim_school schema lookup
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m9s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
dim_school.sql was checking for int_ofsted_latest in target.schema (wrong schema)
due to the custom generate_schema_name macro using literal schema names. The
model lives in 'intermediate', so ofsted_grade/date/framework were always NULL
in dim_school, causing all list cards to show 'Not yet inspected'.

Fix 1: data_loader.py joins marts.fact_ofsted_inspection with DISTINCT ON to
get latest inspection per school — no pipeline re-run needed.

Fix 2: dim_school.sql uses schema='intermediate' so future dbt runs correctly
denormalise the Ofsted summary into dim_school.
2026-04-13 14:51:14 +01:00
Tudor Sitaru 9c50c49e1f fix(map): add effect deps, escape HTML in popup, document Att8 delta threshold
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 24s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
2026-04-13 14:32:55 +01:00
Tudor SitaruandClaude Sonnet 4.6 177571f411 feat(map): rebuild popup as mini card with Ofsted badge and headline metric
Replaces the bare Leaflet popup with a mini card showing school name,
3-state Ofsted badge (OEIF grade / ReportCard / pending), phase tag,
headline metric (Att8 for secondary, RWM% for primary) with delta vs
LA/national average, and a styled View Details button. Threads
nationalAvgRwm and laAverages from HomeView → SchoolMap → LeafletMapInner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:29:55 +01:00
Tudor Sitaru 51310160a8 fix(home): suppress unused nationalAvgRwm param, add ofstedPending badge branch 2026-04-13 14:26:55 +01:00
Tudor SitaruandClaude Sonnet 4.6 2c13b21360 feat(home): fetch national averages, wire to SchoolRow and CompactSchoolItem
- Add nationalAvgRwm state fetched from /api/national-averages on mount
- Pass nationalAvgRwm to SchoolRow (vs-national delta now active in list view)
- Pass nationalAvgRwm to SchoolMap (prop accepted, threaded to Task 7)
- Redesign CompactSchoolItem: Ofsted badge + single headline metric + delta
- Fix stray backslash in SchoolRow.module.css .vsNationalFlat selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:17:34 +01:00
Tudor Sitaru ad2fe5bbef fix(list): remove no-op null coerce and stale comment in SecondarySchoolRow 2026-04-13 14:11:05 +01:00
Tudor SitaruandClaude Sonnet 4.6 58f8eae997 feat(list): remove eng&maths stat, use buildOfstedListBadge in SecondarySchoolRow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:08:16 +01:00
Tudor Sitaru 44fdcfa18b feat(list): redesign primary school card — single metric, vs-national delta, fix label 2026-04-13 13:59:26 +01:00
Tudor Sitaru b1e025d468 style: add ofstedRc, ofstedPending, vsNational CSS classes to row modules 2026-04-13 13:58:46 +01:00
Tudor Sitaru 9ebb421307 fix(utils): tighten ofsted_grade type in buildOfstedListBadge 2026-04-13 13:56:58 +01:00
Tudor SitaruandClaude Sonnet 4.6 8a6758b591 feat(utils): add buildOfstedListBadge helper and fetchNationalAverages
- Add ofsted_framework field to School type
- Add OfstedListBadge interface and buildOfstedListBadge pure function to utils.ts
- Add fetchNationalAverages API function that calls GET /api/national-averages
- Add test suite for buildOfstedListBadge (all 6 new tests pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:52:05 +01:00
Tudor Sitaru 6d02d366ce feat(api): expose ofsted_framework in school list response 2026-04-13 13:45:50 +01:00
Tudor SitaruandClaude Sonnet 4.6 fe31be34a0 fix(secondary): expose GIAS total_pupils in school_info API response
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 22s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The school_info object was missing total_pupils entirely, so the frontend
always fell back to the KS4 exam cohort from yearly_data. Now selects
s.total_pupils (GIAS NumberOfPupils — full school roll) as gias_total_pupils
in the main query and exposes it as total_pupils on school_info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:15:31 +01:00
Tudor SitaruandClaude Sonnet 4.6 109fa14ccb fix(secondary): use GIAS total_pupils for school roll, not KS4 exam cohort
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 16s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
latestResults.total_pupils in KS4 data is the Year 11 exam cohort (~1/7
of the school), not the full school roll. Prefer schoolInfo.total_pupils
(sourced from GIAS NumberOfPupils) which is the statutory census headcount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:04:41 +01:00
Tudor SitaruandClaude Sonnet 4.6 06bf53ac26 fix(dag): remove invalid --select flag from meltano run
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 54s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
meltano run does not support --select; the full tap-uk-ees run already
includes EESKs2NationalStream so no separate task is needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:48:31 +01:00
Tudor SitaruandClaude Sonnet 4.6 dc66e22d4d feat: ingest official DfE KS2 national averages from EES data catalogue
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 19s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m24s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Replaces computed means from our school dataset with the published DfE
national headline figures for the KS2 chart reference line.

- tap-uk-ees: new EESKs2NationalStream fetches the stable EES data-catalogue
  CSV (one row per year, England national total, AllSchools filter)
- dbt staging: stg_ees_ks2_national normalises columns, casts to float,
  filters to years >= 201617
- dbt mart: fact_ks2_national_averages — one row per year, official figures
- backend/models: Ks2NationalAverage SQLAlchemy model
- backend/app: /api/national-averages queries the mart for KS2 by_year;
  secondary by_year stays computed (no DfE KS4 national dataset yet)
- DAG: extract_ks2_national task added to school_data_annual_ees,
  runs in parallel with the main EES extract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:40:33 +01:00
Tudor SitaruandClaude Sonnet 4.6 a3cfffa4d0 feat: national average reference line now tracks per year on history chart
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 24s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m52s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Previously the dashed reference line was a flat horizontal at the latest
year's national average across all historical data, implying the national
figure was constant. Now the backend returns per-year averages in `by_year`
and the chart maps each data year to its own national average, so the
reference line correctly reflects how the national picture changed over time
(including COVID recovery dip/recovery).

- backend: /api/national-averages now includes `by_year` list alongside
  existing `year`/`primary`/`secondary` latest-year snapshot
- types: NationalAverages extended with `by_year: NationalAveragesYear[]`
- PerformanceChart: accepts `nationalByYear` prop; builds per-year series
  aligned to school data years, falling back to scalar prop if absent
- SchoolDetailView + SecondarySchoolDetailView: pass `nationalAvg.by_year`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:55:14 +01:00
Tudor SitaruandClaude Sonnet 4.6 23f881b797 feat(secondary): apply hero design language to secondary school detail view
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Bump school name to clamp(2rem,5vw,3.25rem) Playfair Display
- Add hero signal chips strip (framework-aware Ofsted + coral Oversubscribed)
- Add at-a-glance stats row: Att8 with delta vs national, Ofsted serif tile, first-choice rate
- Active section highlighting in sticky nav via IntersectionObserver
- Collapse OEIF Ofsted section to prose when all sub-grades match overall
- Pass nationalAtt8Avg reference line to PerformanceChart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:05:33 +01:00
Tudor SitaruandClaude Sonnet 4.6 e625addc3b fix(school-detail): move active-section useEffect after navItems declaration
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Failing after 13s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
TypeScript compile error: 'navItems' used before its declaration.
The IntersectionObserver useEffect referenced navItems in its dep array
but was placed above the navItems const declaration. Move it to just
after navItems is built.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:59:01 +01:00
Tudor SitaruandClaude Sonnet 4.6 536a166b35 feat(school-detail): highlight active section in sticky nav on scroll
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 43s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Uses IntersectionObserver on each section element. Multiple thresholds
(0, 0.1, 0.25, 0.5, 0.75, 1.0) track the intersection ratio of every
section simultaneously; whichever has the highest visible ratio at any
moment becomes the active item. rootMargin offsets for the sticky nav
height so a section is only considered active once it's genuinely in
view beneath the bar.

Active link gets .sectionNavLinkActive — coral background + white text,
matching the phase tab active style used elsewhere in the product.
Observer is cleaned up on unmount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:42:20 +01:00
Tudor SitaruandClaude Sonnet 4.6 e72345bad5 refactor(school-detail): remove hero summary, red oversubscribed chip
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 47s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Drop the auto-generated italic summary sentence from the hero — adds
  little beyond what the chips and stats already convey.
- Oversubscribed hero chip: tone-gold → tone-coral so it reads as a
  warning rather than a neutral highlight.
- Remove unused buildSchoolSummary import and .heroSummary CSS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:27:03 +01:00
Tudor SitaruandClaude Opus 4.6 dfa8058efc feat(school-detail): page-wide improvements across 5 sections
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
History chart
- Strip the redundant school-name title (already the page heading)
- Default to 2 visible lines: Reading, Writing & Maths expected % (teal,
  bold) + Exceeding (gold, lighter); progress score lines hidden by
  default, togglable via legend
- Add dashed national average reference line for RWM (primary) or
  Attainment 8 (secondary) so the school's trajectory is always in
  context
- Add trend summary chip above the chart computed from the data
  ("↓ Peaked at 90% (2016/17), currently 70%")
- Add COVID footnote when 2019/20 and 2020/21 data is absent

Ofsted section
- Collapse the four identical "Outstanding / Outstanding / Outstanding /
  Outstanding" boxes into a single prose line when all sub-grades match
  the overall verdict; show individual cards only when grades differ

SATs sub-metrics
- DeltaChip vs national average on Expected level row for Reading,
  Writing and Maths (national averages already in the API response)

Admissions
- Fix label: "Year 3 places per year" → "Reception places per year" for
  primary schools

Pupils & Inclusion
- DeltaChip + national avg hint on Eligible for pupil premium and
  Pupils receiving SEN support (both keys present in /api/national-averages)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:13:13 +01:00
Tudor SitaruandClaude Opus 4.6 41cefeedf6 polish(school-detail): align hero stat numbers on a shared baseline
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 8s
The three at-a-glance stats were misaligned — the serif "Outstanding" tile
sat noticeably above the "70%" and "64%" numerals because the serif variant
used a smaller font. That pushed its label row ("INSPECTED NOVEMBER 2023")
up too, breaking the horizontal rhythm across the row.

- Give .heroStatNumber and .heroStatNumberSerif a shared min-height tied
  to the largest clamp value, plus display: flex; align-items: flex-end.
  Content bottom-aligns inside the box, so every stat's label sits at the
  same Y regardless of how tall the actual glyph is.
- Bump the serif variant up slightly (1.75rem → 2.25rem clamp) so it
  feels closer in weight to the numerals while still leaving room for
  longer words like "Requires Improvement".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:00:45 +01:00
Tudor SitaruandClaude Opus 4.6 1e5c66d6ab fix(admissions): correct first_preference_offer_pct in dbt staging
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The staging model was mapping EES column ``proportion_1stprefs_v_totaloffers``
straight onto ``first_preference_offer_pct``. That raw column is not a
percentage — it is a ratio of first-preference applications to total offers
(an oversubscription indicator, >1 means oversubscribed), so OLQH rendered
as "1%" when the true first-choice success rate is 27/42 = 64%.

The frontend display code is not at fault and is not patched here —
data-quality issues must be fixed at the source.

- stg_ees_admissions: compute ``first_preference_offer_pct`` as
  ``100 * number_1st_preference_offers / times_put_as_1st_preference`` —
  of families who listed this school first, the % that received an offer
  (0–100). Guard against divide-by-zero.
- stg_ees_admissions: expose the legitimate EES ratio as the new column
  ``oversubscription_ratio`` (1st-preference applications per place) for
  future use, clearly named.
- fact_admissions, FactAdmissions model, data_loader: propagate the new
  ``oversubscription_ratio`` column.
- SchoolAdmissions type: document both columns inline.
- buildSchoolSummary: reword the oversubscription clause so it reads
  sensibly across the whole 0–100 range (no more "just 64%").
- Hero chip subtitle: clearer phrasing "X% of first-choice applicants
  offered a place".

Requires a dbt run of stg_ees_admissions and fact_admissions on deploy
so the new column materialises.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 11:29:40 +01:00
Tudor SitaruandClaude Opus 4.6 3458195865 polish(school-detail): align hero chips to fixed width, free hero summary
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 44s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- heroChip: swap ``min-width: 180px; flex: 0 1 auto`` for
  ``flex: 0 0 240px`` so every chip in the strip is the same width
  regardless of content. Title gets nowrap + ellipsis as insurance
  against accidental overflow.
- heroChipTitle / Sub / Detail: align line-heights (1.3 / 1.4 / 1.4)
  so an OEIF chip (title + sub) and a Report Card chip
  (title + sub + detail) sit on the same vertical rhythm.
- heroSummary: drop the 64ch max-width — the sentence should read at
  the natural hero width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 11:18:07 +01:00
Tudor SitaruandClaude Opus 4.6 24b3688df0 polish(school-detail): tighten hero layout and drop redundant chip
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 43s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Drop the "Above national average" chip — the DeltaChip under the 70% /
  Attainment 8 number already carries the same signal, so the chip was
  duplicative and added noise.
- At-a-glance stats: switch from grid(auto-fit) to flex with a fixed
  3rem column gap so the numbers cluster at the start of the row rather
  than spreading across the full width of the header card.
- Add to Compare button: larger padding, bumped font, soft shadow, and
  self-align centre so it sits in balance with the bigger headline
  rather than floating tiny in the top-right corner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 11:04:01 +01:00
Tudor SitaruandClaude Opus 4.6 2d6e39eebc fix(school-detail): hero Ofsted chip mislabels OEIF schools as Report Card
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 45s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The API returns ``framework`` as the literal string "NULL" for older OEIF
inspections (it comes from the upstream ``event_type_grouping`` column),
not real null. The original render path checks ``=== 'ReportCard'`` and
correctly treats anything else as OEIF — but buildOfstedHeroChip inverted
that and treated anything not exactly equal to ``'OEIF'`` as Report Card,
so OLQH (inspected Nov 2023, Outstanding) was being labelled as a Report
Card school in the hero strip and the at-a-glance tile.

- Invert the helper: only branch into Report Card when framework is
  explicitly ``'ReportCard'``; treat OEIF / null / "NULL" / anything else
  as OEIF, and require ``overall_effectiveness`` to render the grade word.
- Replace the toneClass field (which reused .ofstedGrade{N} / .rcGrade{N}
  badge classes and dragged in their backgrounds) with a clean tone enum
  ``teal | green | gold | coral | neutral``. The serif Ofsted heroStat
  picked up the badge background and rendered as a green box around
  "Report Card" — gone now.
- Hero chip backgrounds use color-mix() against the tone variable so all
  five tones share one rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 10:44:37 +01:00
Tudor SitaruandClaude Opus 4.6 c749d72a6a feat(school-detail): editorial hero with signal chips, at-a-glance stats, summary
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Elevates the primary school detail hero from a flat report header into a
scannable editorial block. Parents can read the headline signal in seconds.

- A1: bump .schoolName to clamp(2rem, 5vw, 3.25rem) Playfair.
- A2: framework-aware signal chip strip via new buildOfstedHeroChip() helper.
  Branches on ofsted.framework so Report Card schools never show a fake
  overall grade — they get "Ofsted Report Card" + inspection date +
  Safeguarding: Met/Not met. OEIF schools keep the grade word.
- A3: oversized Playfair stats — Reading, Writing & Maths % (primary) or
  Attainment 8 (secondary) with inline DeltaChip vs national, Ofsted
  verdict with tone colouring, and first-choice offer rate.
- B1: italic serif one-sentence summary via buildSchoolSummary() helper,
  also framework-aware so Report Card schools are described by framework,
  not a synthetic grade.
- C1: new DeltaChip component reused in the two headline KS2 metric cards
  (rwm_expected_pct, rwm_high_pct).

All copy uses "Reading, Writing & Maths" in full. Secondary detail view
untouched in this slice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 10:32:33 +01:00
Tudor SitaruandClaude Opus 4.6 f053b35c6f test(dim_school): downgrade phase not_null to warn
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 45s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m16s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The new phase inference can legitimately leave ~1100 independent schools with
null phase (no GIAS phase, no statutory ages, name gives no hint). That's a
known data quality gap, not a pipeline failure — the UI already handles null
by showing no pill. Downgrade the test to warn so it stays visible in dbt
output without blocking the DAG.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 22:12:57 +01:00
Tudor SitaruandClaude Opus 4.6 ca5f6a962c fix(dim_school): expand phase inference with name-based fallback
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 45s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m10s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The case-insensitive "Not Applicable" fix caught schools where GIAS publishes
statutory ages, but some independent schools leave those blank too — they fall
through every branch and end up with null phase and no pill in the UI.

Add a third tier that infers phase from the school name
(Primary/Infant/Junior/Prep vs Secondary/High/Grammar/Senior/Upper) and also
normalise "Not Applicable" handling with trim() + "unknown"/"" exclusion, so
the final else branch can safely return null instead of the catch-all string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 21:15:54 +01:00
Tudor SitaruandClaude Opus 4.6 ed244ef743 fix(mobile): address iPhone layout issues across rankings, detail, compare
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 45s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- rankings: hide Type/Action columns on mobile so metric value stays visible;
  ensure filter selects and table wrapper stay within viewport
- school detail: add min-width:0 / max-width:100% containment so internal
  overflow-x wrappers actually clip rather than pushing the page wider;
  explicit line-height on Ofsted grade badges to fix glyph clipping
- compare: sticky first column on the Detailed Comparison table so the Year
  labels remain visible while horizontally scrolling school columns
- search: shorten placeholder to "School name or postcode" so it fits mobile
  input width
- globals: overflow-x:clip safety net on .main wrapper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 20:29:15 +01:00
Tudor Sitaru ce46db7dbe shortening placeholder text
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
2026-04-07 16:17:56 +01:00
Tudor SitaruandClaude Opus 4.6 a562f408d2 refactor: expand RWM to "Reading, Writing & Maths" in user-facing text
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 24s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m51s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Expand the abbreviation in metric names (backend schemas), the home page
sort dropdown, README/QA docs, and pipeline comments. Short_name fields
and the compact row/map-card labels remain abbreviated for space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 15:53:52 +01:00
Tudor SitaruandClaude Opus 4.6 5b025b98bd fix(dim_school): use case-insensitive comparison for phase inference
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m6s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
GIAS provides 'Not Applicable' (capital A) but the check used 'Not applicable',
so the case-sensitive != matched true and skipped the age-range inference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:33:04 +01:00
Tudor SitaruandClaude Opus 4.6 4c3c3c882d fix(dim_school): infer phase from age range for independent schools
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m9s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Independent schools have phase='Not applicable' in GIAS. Now infer
phase from statutory age range: <=11 → Primary, >=11 → Secondary,
spans both → All-through. Falls back to original value if no age data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 16:18:52 +01:00
Tudor SitaruandClaude Opus 4.6 d591d8e66b fix(utils): handle null year in formatAcademicYear
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 16:07:28 +01:00
Tudor SitaruandClaude Opus 4.6 4db36b9099 feat(ui): add phase indicators to school list rows
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Add coloured left-border and phase label pill to visually differentiate
school phases (Primary, Secondary, All-through, Post-16, Nursery) in
search result lists. Colours are accessible (WCAG AA) and don't clash
with existing Ofsted/trend semantic colours.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 15:47:51 +01:00
Tudor Sitaru cacbeeb068 fixing backend image
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 17s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
2026-04-01 15:05:21 +01:00
Tudor SitaruandClaude Opus 4.6 d5f6366c28 fix(years): format academic years as 2016/17 across all views, remove legacy frontend and data
Build and Push Docker Images / Build Backend (FastAPI) (push) Failing after 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Apply formatAcademicYear to all year displays in ComparisonChart, ComparisonView,
PerformanceChart, and RankingsView. Remove old vanilla JS frontend and CSV data
directory — both superseded by the Next.js app and Meltano pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 14:58:13 +01:00
Tudor SitaruandClaude Opus 4.6 2b757e556d fix(legacy-ks2): strip % suffix from percentage values
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 34s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m37s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Old DfE CSVs encode percentages as "57%" not "57". The safe_numeric
macro rejects non-numeric strings, so strip the suffix before emitting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 13:07:51 +01:00
Tudor SitaruandClaude Opus 4.6 fbd1de9220 fix(dag): add stg_legacy_ks2 to annual EES dbt build selector
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m29s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 11:27:29 +01:00
Tudor SitaruandClaude Opus 4.6 fba8e74b72 refactor(legacy-ks2): use explicit year→URL mapping instead of base URL pattern
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 34s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
The file hosting uses non-deterministic URLs, so replace legacy_ks2_base_url
+ legacy_ks2_years with a single legacy_ks2_urls object mapping year codes
to download URLs. Configure the 4 pre-COVID years in meltano.yml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 22:44:11 +01:00
Tudor SitaruandClaude Opus 4.6 6d4962639c feat(legacy-ks2): add stream for pre-COVID KS2 data (2015-2019)
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 46s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m17s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m26s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Add LegacyKS2Stream to tap-uk-ees: downloads old DfE england_ks2final.csv
  files from a configurable base URL, maps 318-column wide format to the
  same schema as stg_ees_ks2 output
- Add stg_legacy_ks2.sql staging model with safe_numeric casts
- Add legacy_ks2 source to _stg_sources.yml
- Update int_ks2_with_lineage.sql to union EES + legacy data
- Configurable via legacy_ks2_base_url and legacy_ks2_years tap settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:36:41 +01:00
Tudor SitaruandClaude Opus 4.6 fc011c6547 fix(tap-uk-ees): case-insensitive URN column matching for older census files
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m48s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Older census CSVs use 'URN' (uppercase) while the stream expects 'urn'.
Normalise the column name before filtering and emitting records.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 22:36:16 +01:00
Tudor SitaruandClaude Sonnet 4.6 752abd69a5 fix(tap-uk-ees): inject time_period from release slug when absent in CSV
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m8s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m37s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Older census (and other) files don't include a time_period column.
Derive it from the release slug (e.g. '2022-23' → '202223') and inject
it into records so the required Singer schema field is always present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:59:24 +01:00
Tudor SitaruandClaude Sonnet 4.6 570c2b689e fix(tap-uk-ees): handle plain list response from releases endpoint
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m45s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:47:14 +01:00
Tudor SitaruandClaude Sonnet 4.6 17617137ea fix(data-info): drop NaN years before converting to int
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 47s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:41:11 +01:00
Tudor SitaruandClaude Sonnet 4.6 9a1572ea20 feat(tap-uk-ees): fetch all historical releases, not just latest
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m42s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Add get_all_release_ids() to paginate /publications/{slug}/releases and
iterate over every release in get_records(). Add latest_only config flag
(default false) to restore single-release behaviour for daily runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 21:37:26 +01:00
tudor f48faa1803 showing schools with no KS2 results
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 44s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
2026-03-30 18:14:43 +01:00
tudorandClaude Sonnet 4.6 6e5249aa1e refactor(phase): merge KS2+KS4 into fact_performance, fix all phase inconsistencies
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 50s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m24s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Root cause: the UNION ALL query in data_loader.py produced two rows per
all-through school per year (one KS2, one KS4), with drop_duplicates()
silently discarding the KS4 row. Fixes:

- New dbt mart `fact_performance`: FULL OUTER JOIN of fact_ks2_performance
  and fact_ks4_performance on (urn, year). One row per school per year.
  All-through schools have both KS2 and KS4 columns populated.
- data_loader.py: replace 175-line UNION ALL with a simple JOIN to
  fact_performance. No more duplicate rows or drop_duplicates needed.
- sync_typesense.py: single LATERAL JOIN to fact_performance instead of
  two separate KS2/KS4 joins.
- app.py: remove drop_duplicates (no longer needed); add PHASE_GROUPS
  constant so all-through/middle schools appear in primary and secondary
  filter results (were previously invisible to both); scope result_filters
  gender/admissions_policies to secondary schools only.
- HomeView.tsx: isSecondaryView is now majority-based (not "any secondary")
  and isMixedView shows both sort option sets for mixed result sets.
- school/[slug]/page.tsx: all-through schools route to SchoolDetailView
  (renders both SATs + GCSE sections) instead of SecondarySchoolDetailView
  (KS4-only). Dedicated SEO metadata for all-through schools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:07:30 +01:00
tudorandClaude Sonnet 4.6 695a571c1f fix(filters): forward gender, admissions_policy, has_sixth_form to API
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
These params were present in the URL but never passed to fetchSchools on
the server side, so the backend never applied the filters. Also include
them in hasSearchParams so filter-only searches trigger a fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:45:09 +01:00
tudorandClaude Sonnet 4.6 bd4e71dd30 feat(sort): persist sort order in URL as ?sort= param
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Sort was local state, lost on navigation. Now reads from
searchParams.get('sort') and pushes to URL on change.
'default' removes the param to keep URLs clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:10:28 +01:00
tudorandClaude Sonnet 4.6 cd6a5d092c feat(map): make desktop map view taller using viewport height
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Replace fixed 480px height with calc(100vh - 280px) so the map
fills most of the viewport on any screen size, clamped between
520px and 800px.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:29:12 +01:00
tudorandClaude Sonnet 4.6 5aed055331 feat(map): add fullscreen button using browser Fullscreen API
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Button sits top-right of the map (matching Leaflet control style),
toggles expand/compress icon, and syncs state with Escape key via
the fullscreenchange event.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:21:44 +01:00
tudorandClaude Sonnet 4.6 d6a45b8e12 feat(map): fetch all schools for map view, add reference pin, cap radius at 5mi
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 45s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Remove 10-mile radius option; cap backend radius max at 5 miles
- Raise backend page_size max to 500 so map can fetch all schools in one call
- HomeView: when map view is active, fetch all schools within radius
  (page_size=500) instead of showing only the paginated first page;
  falls back to initial SSR schools while loading
- SchoolMap/LeafletMapInner: accept referencePoint prop and render a
  distinctive coral circle pin at the search postcode location

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:13:14 +01:00
tudorandClaude Sonnet 4.6 daf24e4739 fix(search): fix load-more silently failing due to missing page_size param
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 47s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Load-more requests read URL params (postcode, radius, etc.) but page_size
is never in the URL — it's hardcoded in page.tsx. Without it the backend
received page_size=None, hit a TypeError on (page-1)*None, returned 500,
and the silent catch left the user stuck on page 1.

In a dense area (e.g. Wimbledon SW19) 50 schools fit within ~1.8 miles,
so page 1 never shows anything beyond that regardless of selected radius.

Fix:
- Backend: give page_size a safe default of 25 instead of None
- Frontend: explicitly pass initialSchools.page_size in load-more params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:08:15 +01:00
tudorandClaude Sonnet 4.6 0c5bef34cf feat(ui): polish filter controls with pill styling and custom arrows
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- controlsRow selects: pill shape (border-radius: 999px), appearance:none,
  custom SVG chevron arrow, hover/focus transitions
- advancedToggle: pill button with border, matches select height
- filterSelect (advanced panel): appearance:none, custom arrow, 8px radius,
  focus ring consistent with search input
- clearButton: pill shape matching other controls
- filters panel: separator line and spacing when expanded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:31:03 +01:00
tudorandClaude Sonnet 4.6 5615458223 feat(ui): consolidate search/filter area into cleaner 2-row layout
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Before: 7 visual rows (search, radius row, phase row, advanced toggle,
teal location banner, results count, filter chips).
After: 2 rows in card + 1 unified results toolbar.

- FilterBar: merge radius, phase, and Advanced toggle into a single
  .controlsRow below the search bar; removed orphaned stacked rows
- HomeView: remove separate teal location banner; merge location info
  into results heading ("16 schools within 1.0 miles of SW196AR");
  move List/Map toggle inline with sort in one results header row
- Remove "Near postcode (Xmi)" chip (now redundant with heading)
- Sort select hidden in map view (sorting is meaningless there)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:46:38 +01:00
tudorandClaude Sonnet 4.6 9c9528b51b fix(FilterBar): close fragment opened around phase filter and advanced section
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:21:11 +01:00
tudorandClaude Sonnet 4.6 1009d7c976 fix(chart): format year as 2022/23 instead of 202223 on performance chart
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:17:04 +01:00
tudor 790b12a7f3 changes to order of display and text
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 55s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
2026-03-29 20:14:42 +01:00
tudorandClaude Sonnet 4.6 8f4c052294 feat(filters): move phase filter out of advanced section to always-visible position
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 1m3s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Phase is a common filter (primary vs secondary) so it now appears
between the search form and the Advanced filters toggle rather than
being hidden inside the collapsible section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:33:45 +01:00
tudorandClaude Sonnet 4.6 b7bff7bf6b feat(seo): static sitemap generation job via Airflow
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 45s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m29s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
- Backend builds sitemap.xml from school data at startup (in-memory)
- POST /api/admin/regenerate-sitemap refreshes it after data updates
- New Airflow DAG (sitemap_generate) runs Sundays 05:00 and calls the endpoint
- Next.js proxies /sitemap.xml to the backend; removes the slow dynamic sitemap.ts
- docker-compose passes BACKEND_URL + ADMIN_API_KEY to Airflow env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:15:41 +01:00
tudorandClaude Sonnet 4.6 748891ab31 fix(detail): restore admissions cut-off note on secondary school page
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:07:52 +01:00
tudorandClaude Sonnet 4.6 17b8873f0f fix(detail): add missing location map section to secondary school page
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Frontend (Next.js) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:06:27 +01:00
tudorandClaude Opus 4.6 15c0055687 refactor(detail): align secondary school page with primary — scroll-to-section nav
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Replace tab-based show/hide with always-visible sections and anchor
link navigation, matching the primary school detail page behaviour.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 14:48:06 +01:00
tudorandClaude Opus 4.6 6315f366c8 fix(build): single-brace JSX for schoolUrl, migrate images.domains to remotePatterns
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 14:15:06 +01:00
tudorandClaude Opus 4.6 784febc162 feat(seo): add school name to URLs, fix sticky nav, collapse compare widget
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
- URLs now /school/138267-school-name instead of /school/138267
- Bare URN URLs redirect to canonical slug (backward compat)
- Remove overflow-x:hidden that broke sticky tab nav on secondary pages
- ComparisonToast starts collapsed — user must click to open

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 12:41:28 +01:00
tudorandClaude Opus 4.6 e2c700fcfc fix(ui): round admission percentages, fix mobile overflow on detail pages
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 11:22:13 +01:00
tudorandClaude Opus 4.6 77a0f5b674 fix(detail): enrich secondary overview tab — show Ofsted grades, admissions, SEN
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 37s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The overview tab was sparse for schools without parent view data, showing
only 2 cards. Now shows:
- Individual Ofsted grades when no overall effectiveness (post-Sept 2024)
- Admissions summary card (PAN, applications, 1st choice rate)
- School context card (pupils, capacity, SEN support, EHCP)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:59:30 +01:00
tudorandClaude Opus 4.6 63dfa22255 fix(detail): detect secondary schools by attainment_8_score, not just phase field
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Schools with phases like "All-through" or null phase but with GCSE data
were falling through to the primary SchoolDetailView, rendering only
partial content. Now checks yearly_data for attainment_8_score as well.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:12:07 +01:00
tudorandClaude Opus 4.6 1d22877aec feat(ux): 8 UX improvements — simpler home, advanced filters, phase tabs, 4-line rows
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 48s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m13s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
1. Simpler home page: only search box on landing, no filter dropdowns
2. Advanced filters: hidden behind toggle on results page, auto-open if active
3. Per-school phase rendering: each row renders based on its own data
4. Taller 4-line rows with context line (type, age range, denomination, gender)
5. Result-scoped filters: dropdown values reflect current search results
6. Fix blank filter values: exclude empty strings and "Not applicable"
7. Rankings: Primary/Secondary phase tabs with phase-specific metrics
8. Compare: Primary/Secondary tabs with school counts and phase metrics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 08:57:06 +01:00
tudor e8175561d5 updates for secondary schools
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 46s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m15s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
2026-03-28 22:36:00 +00:00
tudorandClaude Sonnet 4.6 f3a8ebdb4b fix(dbt): deduplicate int_ks4_with_lineage predecessor rows
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
When multiple predecessor URNs exist for the same current school and
year, use DISTINCT ON to keep the one with the most pupils — matching
the same logic already in int_ks2_with_lineage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:58:50 +00:00
tudorandClaude Sonnet 4.6 f0c76a1724 fix(dbt): fix stg_ees_ks4 breakdown filter: 'Total' not 'All pupils'
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 31s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m26s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
The EES KS4 performance CSV uses breakdown_topic='Total' for the
all-pupils aggregate, not 'All pupils' as the model assumed. This
caused 0 rows to pass the filter despite 40k rows in raw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:35:00 +00:00
tudorandClaude Sonnet 4.6 3e787b395f chore(pipeline): add EES KS4 tap diagnostic script
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 2m28s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m28s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:26:15 +00:00
tudorandClaude Sonnet 4.6 3d1c4c61c9 fix(types): add missing phases field to Filters fallback in rankings page
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:03:03 +00:00
tudorandClaude Sonnet 4.6 250d1f7c77 fix(tap-uk-idaci): add openpyxl dependency for Excel file parsing
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 49s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 1m2s
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:00:00 +00:00
tudorandClaude Sonnet 4.6 5eff9af69c feat: add secondary school support with KS4 data and metric tooltips
Build and Push Docker Images / Build Frontend (Next.js) (push) Has been cancelled
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Backend (FastAPI) (push) Has been cancelled
- Backend: replace INNER JOIN ks2 with UNION ALL (ks2 + ks4) so primary
  and secondary schools both appear in the main DataFrame
- Backend: add /api/national-averages endpoint computing means from live
  data, replacing the hardcoded NATIONAL_AVG constant on the frontend
- Backend: add phase filter param to /api/schools; return phases from
  /api/filters; fix hardcoded "phase": "Primary" in school detail endpoint
- Backend: add KS4 metric definitions (Attainment 8, Progress 8, EBacc,
  English & Maths pass rates) to METRIC_DEFINITIONS and RANKING_COLUMNS
- Frontend: SchoolDetailView is now phase-aware — secondary schools show
  a GCSE Results section (Att8, P8, E&M, EBacc) instead of SATs; phonics
  tab hidden for secondary; admissions says Year 7 instead of Year 3;
  history table shows KS4 columns; chart datasets switch for secondary
- Frontend: new MetricTooltip component (CSS-only ⓘ icon) backed by
  METRIC_EXPLANATIONS — added to RWM, GPS, SEN, EAL, IDACI, progress
  scores and all KS4 metrics throughout SchoolDetailView and SchoolCard
- Frontend: METRIC_EXPLANATIONS extended with KS4 terms (Attainment 8,
  Progress 8, EBacc) and previously missing terms (SEN, EHCP, EAL, IDACI)
- Frontend: SchoolCard expands "RWM" to "Reading, Writing & Maths" and
  shows Attainment 8 / English & Maths Grade 4+ for secondary schools
- Frontend: FilterBar adds Phase dropdown (Primary / Secondary / All-through)
- Frontend: HomeView hero copy updated; compact list shows phase-aware metric
- Global metadata updated to remove "primary only" framing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:59:40 +00:00
tudorandClaude Sonnet 4.6 b0990e30ee fix(ui): retheme comparison toast to match site palette
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 34s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m28s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Switch from dark (#1a1612) to site's warm cream background
- Clear all button now visible as a text button with muted/coral hover
- Remove scroll bar: no max-height cap needed since 5 schools max
- Compare Now button uses coral accent to match primary CTAs
- School items use bg-secondary (beige) consistent with site cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:09:59 +00:00
tudorandClaude Sonnet 4.6 1629a8f994 feat(pipeline): add DAGs for Parent View and IDACI deprivation
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 34s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
- school_data_monthly_parent_view: runs 1st of month, extracts Ofsted
  Parent View and builds fact_parent_view
- school_data_annual_idaci: manual trigger, extracts IDACI deprivation
  index and builds fact_deprivation

Both tables were missing, causing safe_query to fail and leave the
PostgreSQL transaction in an aborted state, silently killing all
subsequent supplementary data queries including fact_admissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:08:12 +00:00
tudorandClaude Sonnet 4.6 55749bdfaf debug(backend): log safe_query exceptions and rollback on failure
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 45s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:00:27 +00:00
tudorandClaude Sonnet 4.6 cd1c649d0f fix(frontend): format 6-digit EES academic year codes correctly
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
formatAcademicYear now handles both 4-digit (2023→2023/24) and 6-digit
EES codes (202526→2025/26). Applied to all year displays: SATs, phonics,
admissions, finances, and the yearly results table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 18:30:37 +00:00
tudorandClaude Sonnet 4.6 7724fe3503 fix(stg_ofsted_inspections): correctly filter NULL string inspection dates
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m25s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The string 'NULL' is not SQL NULL, so the WHERE in the renamed CTE
passed those rows through. Filter on the raw value using nullif in the
CTE and on the computed date in the outer SELECT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 18:21:30 +00:00
tudorandClaude Sonnet 4.6 1d56eebe87 fix(stg_ofsted_inspections): filter out rows with no inspection date
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m5s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m24s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Schools in the MI file that have never been inspected have a null
inspection_date after parsing. Exclude them — they are not inspection
records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:55:11 +00:00
tudorandClaude Sonnet 4.6 10720400fd fix(stg_ofsted_inspections): parse DD/MM/YYYY date format from Ofsted CSV
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m3s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m28s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:34:34 +00:00
tudorandClaude Sonnet 4.6 05cb22f1a5 fix(stg_ofsted_inspections): handle NULL strings from Ofsted CSV
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m26s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Use nullif+trim for date cast and safe_numeric for integer grades to
handle literal 'NULL' strings present in the new Report Card format CSV.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:23:46 +00:00
tudorandClaude Sonnet 4.6 26aa3c2d70 fix(tap-uk-ofsted): fix header row detection matching 'urn' inside 'turn'
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m40s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The preamble row in Ofsted CSVs contains 'turn off all filters' which
matched 'urn' in line.lower(), so header_idx was set to 0 instead of
the real header row. Use a regex that matches URN only as a CSV field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:05:03 +00:00
tudorandClaude Sonnet 4.6 e56a63c59c debug(tap-uk-ofsted): log CSV column names to diagnose 0-record extraction
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 31s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m40s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:47:32 +00:00
tudorandClaude Sonnet 4.6 221923857d chore: remove integrator/kestra CI jobs, fix school website link protocol
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m4s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 30s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Remove build-integrator and build-kestra-init jobs from Gitea Actions
- Update trigger-deployment needs to only depend on remaining three builds
- Fix school website href to prepend https:// when protocol is missing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:30:08 +00:00
tudorandClaude Sonnet 4.6 62284e7a94 chore: remove Kestra and integrator legacy services
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 35s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Integrator (push) Failing after 30s
Build and Push Docker Images / Build Kestra Init (push) Failing after 29s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 30s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Migration to Airflow + Meltano pipeline is complete. Remove:
- kestra, kestra-init, integrator services from docker-compose.portainer.yml
- kestra_storage and supplementary_data volumes
- KESTRA_USER/KESTRA_PASSWORD env var references
- integrator/ directory (Kestra flows, scripts, Dockerfiles)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:03:34 +00:00
tudorandClaude Sonnet 4.6 668e234eb2 feat(census): add demographic columns to EES census tap and staging models
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m39s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
tap-uk-ees: EESCensusStream now declares 27 data columns (FSM %, EAL %,
ethnicity breakdowns, pupil counts) with clean Singer field names mapped
from the verbose CSV column names (e.g. '% of pupils known to be eligible
for free school meals' → fsm_pct) via a new _column_renames mechanism on
the base stream class.

stg_ees_census: materialised as table, applies safe_numeric to all
percentage/count columns, filters to numeric URNs.

int_pupil_chars_merged + fact_pupil_characteristics: pass all columns
through from staging (previously stubs with only 3 columns).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:07:48 +00:00
tudorandClaude Sonnet 4.6 4b02ab3d8a feat: wire Typesense search into backend, fix sync performance data bug
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m1s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m25s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
sync_typesense.py:
- Fix query string replacement: was matching 'ST_X(l.geom) as lng' but
  QUERY_BASE uses 'l.longitude as lng' — KS2/KS4 lateral joins were
  silently dropped on every sync run

backend:
- Add typesense_url/typesense_api_key settings to config.py
- Add search_schools_typesense() to data_loader.py — queries Typesense
  'schools' alias, returns URNs in relevance order with typo tolerance;
  falls back to empty list if Typesense is unavailable
- /api/schools: replace pandas str.contains with Typesense search;
  results are filtered from the DataFrame and returned in relevance order;
  graceful fallback to substring match if Typesense is down

requirements.txt: add typesense==0.21.0, numpy==1.26.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 13:23:32 +00:00
tudorandClaude Sonnet 4.6 5d8b319451 fix(dbt): stub rc_* columns as NULL in stg_ofsted_inspections
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Integrator (push) Successful in 56s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m23s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
tap-uk-ofsted schema only declares OEIF columns; rc_* (Report Card)
columns were never emitted so they don't exist in raw.ofsted_inspections.
Replace column references with NULL::text until the actual CSV column
names for the post-Nov 2025 Report Card framework are confirmed and
added to the tap schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:50:58 +00:00
tudorandClaude Sonnet 4.6 77f75fb6e5 fix(dbt): deduplicate predecessor KS2 rows and downgrade orphan test to warn
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Integrator (push) Successful in 56s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
- int_ks2_with_lineage: use DISTINCT ON (current_urn, year) in predecessor_ks2
  to handle schools with multiple predecessors that both have KS2 data for the
  same year (e.g. two schools that merged). Keeps the predecessor with most pupils.
- dbt_project.yml: downgrade assert_no_orphaned_facts to warn severity — the 10
  orphaned URNs are closed schools in EES data not present in GIAS/dim_school;
  they don't surface in the backend which joins on dim_school anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:16:36 +00:00
tudorandClaude Sonnet 4.6 b41e6c250e fix(dbt): filter non-numeric URNs and trim whitespace in EES staging models
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m30s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
- Filter school_urn/time_period to '^[0-9]+$' to exclude "n/a" and other
  non-numeric values that caused integer cast failures in fact_admissions
- Add trim() to all school_urn/time_period casts to prevent whitespace
  variants producing duplicate urn+year rows in fact_ks2_performance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:00:30 +00:00
tudorandClaude Sonnet 4.6 6e720feca4 perf(dbt): collapse stg_ees_ks2 to single-pass pivot
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m7s
Build and Push Docker Images / Build Integrator (push) Successful in 56s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Previous version scanned ees_ks2_attainment (1.2M rows) 5 times via
separate CTEs (all_pupils, gender_boys, gender_girls, disadv, not_disadv)
plus 5 LEFT JOINs. Rewritten as one GROUP BY with conditional aggregation
— single scan, no self-joins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 11:42:40 +00:00
tudorandClaude Sonnet 4.6 ae9fd26eba perf(dbt): materialize stg_ees_ks2 and stg_ees_ks4 as tables
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Integrator (push) Successful in 57s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m30s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
KS2 attainment has 1.2M rows in long format. As a view, the pivot was
re-executed inline for every downstream model (intermediate → fact),
causing fact_ks2_performance CREATE TABLE to run for 18+ minutes.

Materializing as tables means the pivot runs once during staging, and
downstream models read from a pre-computed ~16k-row result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 11:20:20 +00:00
tudorandClaude Sonnet 4.6 33b395d2bd fix(dbt): apply safe_numeric macro to fix EES suppression code 'c' errors
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m14s
Build and Push Docker Images / Build Integrator (push) Successful in 58s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m25s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Replace nullif(col, 'z') casts with safe_numeric macro across KS2, KS4,
and admissions staging models. The regex-based macro treats any non-numeric
string (z, c, x, q, u, etc.) as NULL without needing an explicit list.

Also fix FSM_eligible_percent column quoting in stg_ees_admissions — target-
postgres stores mixed-case column names quoted, so unquoted references were
being folded to fsm_eligible_percent by PostgreSQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 10:41:27 +00:00
tudorandClaude Sonnet 4.6 8e8d1bd8c5 fix(ees-tap): filter out rows with null URN before emitting
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m10s
Build and Push Docker Images / Build Integrator (push) Successful in 56s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m47s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
The admissions school-level file contains some rows with null school_urn
(LA/category aggregates that survive the geographic_level filter). These
cause a not-null constraint violation at target-postgres. Drop any row
where the URN column is null or empty before yielding records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 10:13:17 +00:00
tudorandClaude Sonnet 4.6 c7357336e3 fix(ees-tap): fix BOM handling for admissions CSV
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m6s
Build and Push Docker Images / Build Integrator (push) Successful in 57s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m40s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Admissions file is UTF-8 with BOM, not Latin-1. Reading as latin-1
decoded the BOM bytes as '' which wasn't stripped. Change admissions
encoding to utf-8-sig (strips BOM automatically). Also update the manual
BOM strip fallback to handle the latin-1 decoded form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 10:03:17 +00:00
tudorandClaude Sonnet 4.6 b8ecc5c58b fix(ees-tap): strip UTF-8 BOM from CSV column names
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m42s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Some DfE supporting-files CSVs have a UTF-8 BOM on the first column,
causing it to be named '\ufefftime_period' instead of 'time_period'.
This trips Singer schema validation ('time_period' is a required property).
Strip the BOM from all column names after read_csv.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:54:15 +00:00
tudorandClaude Sonnet 4.6 f4f0257447 fix(ees-tap): add latin-1 encoding for census/admissions, default utf-8 for others
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 52s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m8s
Build and Push Docker Images / Build Integrator (push) Successful in 55s
Build and Push Docker Images / Build Kestra Init (push) Successful in 31s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m40s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
DfE supporting-files CSVs (spc_school_level_underlying_data, AppsandOffers
SchoolLevel) are Latin-1 encoded. Add _encoding class attribute to base
stream class and override to 'latin-1' for census and admissions streams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:41:40 +00:00
tudorandClaude Sonnet 4.6 ca351e9d73 feat: migrate backend to marts schema, update EES tap for verified datasets
Pipeline:
- EES tap: split KS4 into performance + info streams, fix admissions filename
  (SchoolLevel keyword match), fix census filename (yearly suffix), remove
  phonics (no school-level data on EES), change endswith → in for matching
- stg_ees_ks4: rewrite to filter long-format data and extract Attainment 8,
  Progress 8, EBacc, English/Maths metrics; join KS4 info for context
- stg_ees_admissions: map real CSV columns (total_number_places_offered, etc.)
- stg_ees_census: update source reference, stub with TODO for data columns
- Remove stg_ees_phonics, fact_phonics (no school-level EES data)
- Add ees_ks4_performance + ees_ks4_info sources, remove ees_ks4 + ees_phonics
- Update int_ks4_with_lineage + fact_ks4_performance with new KS4 columns
- Annual EES DAG: remove stg_ees_phonics+ from selector

Backend:
- models.py: replace all models to point at marts.* tables with schema='marts'
  (DimSchool, DimLocation, KS2Performance, FactOfstedInspection, etc.)
- data_loader.py: rewrite load_school_data_as_dataframe() using raw SQL joining
  dim_school + dim_location + fact_ks2_performance; update get_supplementary_data()
- database.py: remove migration machinery, keep only connection setup
- app.py: remove check_and_migrate_if_needed, remove /api/admin/reimport-ks2
  endpoints (pipeline handles all imports)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:29:27 +00:00
235 changed files with 21428 additions and 410865 deletions
Vendored
BIN
View File
Binary file not shown.
@@ -1,19 +1,14 @@
name: Build and Push Docker Images
name: Deploy (staging -> E2E gate -> production)
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
INTEGRATOR_IMAGE_NAME: ${{ gitea.repository }}-integrator
KESTRA_INIT_IMAGE_NAME: ${{ gitea.repository }}-kestra-init
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs:
@@ -47,17 +42,15 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=backend-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha
type=raw,value=staging
- name: Build and push Backend Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: ${{ gitea.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
@@ -93,112 +86,20 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=frontend-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha
type=raw,value=staging
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./nextjs-app
file: ./nextjs-app/Dockerfile
push: ${{ gitea.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
build-args: |
FASTAPI_URL=http://backend:80/api
# Cache disabled due to registry size limits
# cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache
# cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache,mode=max
build-integrator:
name: Build Integrator
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["10.0.1.224:6000"]
[registry."10.0.1.224:6000"]
http = true
insecure = true
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata for Integrator Docker image
id: meta-integrator
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.INTEGRATOR_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=integrator-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
- name: Build and push Integrator Docker image
uses: docker/build-push-action@v5
with:
context: ./integrator
file: ./integrator/Dockerfile
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta-integrator.outputs.tags }}
labels: ${{ steps.meta-integrator.outputs.labels }}
build-kestra-init:
name: Build Kestra Init
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["10.0.1.224:6000"]
[registry."10.0.1.224:6000"]
http = true
insecure = true
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata for Kestra Init Docker image
id: meta-kestra-init
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.KESTRA_INIT_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=kestra-init-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
- name: Build and push Kestra Init Docker image
uses: docker/build-push-action@v5
with:
context: ./integrator
file: ./integrator/Dockerfile.init
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta-kestra-init.outputs.tags }}
labels: ${{ steps.meta-kestra-init.outputs.labels }}
build-pipeline:
name: Build Pipeline (Meltano + dbt + Airflow)
@@ -230,28 +131,110 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=pipeline-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha
type=raw,value=staging
- name: Build and push Pipeline Docker image
uses: docker/build-push-action@v5
with:
context: ./pipeline
file: ./pipeline/Dockerfile
push: ${{ gitea.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta-pipeline.outputs.tags }}
labels: ${{ steps.meta-pipeline.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache,mode=max
trigger-deployment:
name: Trigger Portainer Update
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [build-backend, build-frontend, build-integrator, build-kestra-init, build-pipeline]
if: gitea.event_name != 'pull_request'
needs: [build-backend, build-frontend, build-pipeline]
steps:
- name: Trigger Portainer stack update
- name: Trigger staging stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_STAGING_WEBHOOK }}"
- name: Wait for staging to become healthy
run: |
curl -X POST -k "https://10.0.1.224:9443/api/stacks/webhooks/863fc57c-bf24-4c63-9001-bdf9912fba73"
echo "Polling ${STAGING_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${STAGING_BASE_URL}/"; then
echo "Staging is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Staging did not become healthy in time" >&2
exit 1
env:
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
e2e-staging:
name: E2E Journeys against Staging
runs-on: ubuntu-latest
needs: [deploy-staging]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Playwright
working-directory: e2e
run: |
npm ci
npx playwright install --with-deps chromium
- name: Run E2E journeys
working-directory: e2e
run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
promote-prod:
name: Promote to Production
runs-on: ubuntu-latest
needs: [e2e-staging]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Retag verified images as prod
run: |
SHORT_SHA="sha-$(echo "${{ gitea.sha }}" | cut -c1-7)"
for IMAGE in \
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
# Keep a rollback pointer before moving :prod
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
done
- name: Trigger production stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
- name: Wait for production to become healthy
run: |
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
echo "Production is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Production did not become healthy in time" >&2
exit 1
env:
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
+191
View File
@@ -0,0 +1,191 @@
name: PR Checks
on:
pull_request:
branches:
- main
env:
REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs:
frontend-checks:
name: Frontend Typecheck + Tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: nextjs-app/package-lock.json
- name: Install dependencies
working-directory: nextjs-app
run: npm ci
- name: Typecheck
working-directory: nextjs-app
run: npm run typecheck
- name: Unit tests
working-directory: nextjs-app
run: npm test
backend-checks:
name: Backend Smoke
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt pytest "httpx<0.28"
- name: Import smoke test
run: python -c "from backend.app import app; print('backend imports OK')"
- name: Backend unit tests
run: python -m pytest backend/tests -q
build-backend:
name: Build Backend (no push)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["10.0.1.224:6000"]
[registry."10.0.1.224:6000"]
http = true
insecure = true
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build Backend Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: false
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:buildcache
build-frontend:
name: Build Frontend (no push)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["10.0.1.224:6000"]
[registry."10.0.1.224:6000"]
http = true
insecure = true
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build Frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./nextjs-app
file: ./nextjs-app/Dockerfile
push: false
build-args: |
FASTAPI_URL=http://backend:80/api
build-pipeline:
name: Build Pipeline (no push)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["10.0.1.224:6000"]
[registry."10.0.1.224:6000"]
http = true
insecure = true
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build Pipeline Docker image
uses: docker/build-push-action@v5
with:
context: ./pipeline
file: ./pipeline/Dockerfile
push: false
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PIPELINE_IMAGE_NAME }}:buildcache
ai-review:
name: AI Code Review (Claude)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Review PR diff with Claude Code
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Auto-provided per-run token from Gitea Actions (repo-scoped).
# GITHUB_TOKEN is the documented name; GITEA_TOKEN is its alias.
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
BASE_REF: ${{ gitea.event.pull_request.base.ref }}
run: python scripts/ci/ai_review.py
+1 -1
View File
@@ -1,2 +1,2 @@
venv
backend/__pycache__
__pycache__/
-3
View File
@@ -22,13 +22,10 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY backend/ ./backend/
COPY frontend/ ./frontend/
COPY scripts/ ./scripts/
COPY data/ ./data/
# Expose the application port
EXPOSE 80
# Run the application (using module import)
CMD ["python", "-m", "uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "80"]
+71
View File
@@ -0,0 +1,71 @@
# Mobile design baseline
Mobile (≥55% of traffic) is the primary target for this app. Any new
screen or component must be designed at the **360 px** viewport first
and verified at three reference widths before merge.
## Reference viewports
| Width | Device class | Purpose |
| --- | --- | --- |
| 360 px | Low-end Android (Samsung A-series, older Pixels) | Hard floor — if it doesn't fit here it isn't shipping |
| 390 px | iPhone 14 / 15 / 16 (38% of mobile traffic) | Primary iOS target |
| 430 px | iPhone 16 Pro Max, large Android | Upper mobile bound |
## Acceptance checks for any screen change
Before raising a PR that touches user-visible UI, confirm at each
reference width:
1. **No horizontal overflow.** `document.documentElement.scrollWidth ===
window.innerWidth`. The most reliable check: in DevTools console run
```js
document.documentElement.scrollWidth - innerWidth
```
It must read `0`. Any positive number means something is bleeding
past the right edge — usually a fixed-width element, an inline-block
that didn't wrap, or a flex row missing `flex-wrap: wrap`.
2. **Tap targets ≥ 44 × 44 px** on every interactive element (iOS Human
Interface Guidelines minimum). Probe with:
```js
Array.from(document.querySelectorAll('a, button, [role=button], input, select'))
.filter(el => el.offsetParent)
.map(el => ({ t: el.innerText?.trim().slice(0,30), r: el.getBoundingClientRect() }))
.filter(o => o.r.width < 44 || o.r.height < 44)
```
3. **No text below 11 px** in any visible-by-default block. Decorative
demo content (illustrations, mocked previews) should either scale up
or be hidden under the `640 px` breakpoint — see `MOB-04` for the
pattern used on the home page's "What you'll see" section.
4. **iOS Chrome bottom-bar parity.** The fixed `Navigation` bottom tab
bar already compensates for the auto-hiding URL bar via the Visual
Viewport API (`Navigation.tsx`). New fixed-bottom elements must
either use the same offset (read `var(--mobile-bar-offset)`) or sit
inside the existing tab-bar container.
5. **Safe-area insets** on any new sticky/fixed chrome:
`padding-bottom: env(safe-area-inset-bottom)` for bottom-pinned UI,
`padding-inline: env(safe-area-inset-left/right)` for header-class
chrome that runs full bleed.
6. **`dvh`, not `vh`.** iOS Safari's collapsing toolbar makes raw `vh`
units jump. Prefer `100dvh` (with a `100vh` fallback if you support
older engines) for any height that needs to track the visible
viewport.
## Component patterns
- **Hide-on-mobile decoration:** wrap with `@media (max-width: 640px) {
.x { display: none; } }` — examples in `HomeView.module.css`
(`.hiwVisual`), `MetricTooltip.module.css` (`.wrapper`).
- **Right-edge scroll-fade for horizontal scrollers:**
`mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);`
Drop the fade when scrolled to the end with a JS-toggled class — see
`SchoolDetailView.tsx`'s `sectionNavAtEnd` state for the pattern.
## Automation (future)
A Playwright regression test that asserts `docW === vw` at the three
reference widths on `/`, `/rankings`, `/admissions`, `/compare`, and a
representative `/school/:urn` page would catch overflow regressions
immediately. Not added yet — Playwright isn't currently in the project
dependency set, and the existing Jest setup doesn't compute layout.
Worth adding if mobile overflow regressions recur.
+2 -2
View File
@@ -26,7 +26,7 @@ The application tracks these Key Stage 2 performance indicators:
| **Reading Expected %** | Percentage meeting expected standard in reading |
| **Writing Expected %** | Percentage meeting expected standard in writing |
| **Maths Expected %** | Percentage meeting expected standard in maths |
| **RWM Combined %** | Percentage meeting expected standard in all three subjects |
| **Reading, Writing & Maths Combined %** | Percentage meeting expected standard in all three subjects |
## Quick Start
@@ -131,7 +131,7 @@ If using your own CSV data, ensure it includes these columns (or similar):
| READPROG | Float | Reading progress score |
| WRITPROG | Float | Writing progress score |
| MATPROG | Float | Maths progress score |
| PTRWM_EXP | Float | % meeting expected standard in RWM |
| PTRWM_EXP | Float | % meeting expected standard in reading, writing & maths |
| PTREAD_EXP | Float | % meeting expected standard in reading |
| PTWRIT_EXP | Float | % meeting expected standard in writing |
| PTMAT_EXP | Float | % meeting expected standard in maths |
+427 -110
View File
@@ -1,9 +1,10 @@
"""
SchoolCompare.co.uk API
Serves primary school (KS2) performance data for comparing schools.
Serves primary and secondary school performance data for comparing schools.
Uses real data from UK Government Compare School Performance downloads.
"""
import hashlib
import re
from contextlib import asynccontextmanager
from typing import Optional
@@ -12,6 +13,7 @@ import numpy as np
import pandas as pd
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from slowapi import Limiter, _rate_limit_exceeded_handler
@@ -24,14 +26,93 @@ from .config import settings
from .data_loader import (
clear_cache,
load_school_data,
load_latest_school_data,
geocode_single_postcode,
get_supplementary_data,
search_schools_typesense,
)
from .data_loader import get_data_info as get_db_info
from .database import check_and_migrate_if_needed
from .migration import run_full_migration
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
from .utils import clean_for_json
from .utils import clean_for_json, convert_to_native
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
# Maps user-facing phase filter values to the GIAS PhaseOfEducation values they include.
# All-through schools appear in both primary and secondary results.
PHASE_GROUPS: dict[str, set[str]] = {
"primary": {"primary", "middle deemed primary", "all-through"},
"secondary": {"secondary", "middle deemed secondary", "all-through", "16 plus"},
"all-through": {"all-through"},
}
BASE_URL = "https://schoolcompare.co.uk"
MAX_SLUG_LENGTH = 60
# In-memory sitemap cache
_sitemap_xml: str | None = None
def _slugify(text: str) -> str:
text = text.lower()
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def _school_url(urn: int, school_name: str) -> str:
slug = _slugify(school_name)
if len(slug) > MAX_SLUG_LENGTH:
slug = slug[:MAX_SLUG_LENGTH].rstrip("-")
return f"/school/{urn}-{slug}"
def build_sitemap() -> str:
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
df = load_school_data()
static_urls = [
(BASE_URL + "/", "daily", "1.0"),
(BASE_URL + "/rankings", "weekly", "0.8"),
(BASE_URL + "/compare", "weekly", "0.8"),
]
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for url, freq, priority in static_urls:
lines.append(
f" <url><loc>{url}</loc>"
f"<changefreq>{freq}</changefreq>"
f"<priority>{priority}</priority></url>"
)
if not df.empty and "urn" in df.columns and "school_name" in df.columns:
seen = set()
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
urn = int(row["urn"])
name = str(row["school_name"])
if urn in seen:
continue
seen.add(urn)
path = _school_url(urn, name)
lines.append(
f" <url><loc>{BASE_URL}{path}</loc>"
f"<changefreq>monthly</changefreq>"
f"<priority>0.6</priority></url>"
)
lines.append("</urlset>")
return "\n".join(lines)
def clean_filter_values(series: pd.Series) -> list[str]:
"""Return sorted unique values from a Series, excluding NaN and junk labels."""
return sorted(
v for v in series.dropna().unique().tolist()
if v not in EXCLUDED_FILTER_VALUES
)
# =============================================================================
@@ -86,6 +167,69 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
return response
# Per-path Cache-Control rules. Keys are matched as path prefixes (longest wins).
# Values: (max_age, s_maxage, stale_while_revalidate)
CACHE_RULES: list[tuple[str, tuple[int, int, int]]] = [
("/api/filters", (300, 86400, 604800)),
("/api/metrics", (300, 86400, 604800)),
("/api/national-averages", (300, 86400, 604800)),
("/api/la-averages", (300, 86400, 604800)),
("/api/data-info", (300, 86400, 604800)),
("/api/schools/", (300, 3600, 86400)), # /api/schools/{urn}
("/api/rankings", (60, 600, 3600)),
("/api/compare", (60, 600, 3600)),
("/api/schools", (30, 300, 1800)), # search list
]
def _cache_control_for_path(path: str) -> Optional[str]:
# Longest-prefix match
best: Optional[tuple[int, tuple[int, int, int]]] = None
for prefix, vals in CACHE_RULES:
if path.startswith(prefix) and (best is None or len(prefix) > best[0]):
best = (len(prefix), vals)
if best is None:
return None
max_age, s_maxage, swr = best[1]
return f"public, max-age={max_age}, s-maxage={s_maxage}, stale-while-revalidate={swr}"
class CacheAndETagMiddleware(BaseHTTPMiddleware):
"""Set Cache-Control on cacheable API responses and serve 304s via ETag."""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Only cache GETs that succeeded.
if request.method != "GET" or response.status_code != 200:
return response
cache_header = _cache_control_for_path(request.url.path)
if cache_header is None:
return response
# Drain body so we can hash it for ETag.
body_chunks = []
async for chunk in response.body_iterator:
body_chunks.append(chunk)
body = b"".join(body_chunks)
etag = '"' + hashlib.md5(body).hexdigest() + '"'
headers = dict(response.headers)
headers["Cache-Control"] = cache_header
headers["ETag"] = etag
headers["Vary"] = ", ".join(filter(None, [headers.get("Vary"), "Accept-Encoding"]))
inm = request.headers.get("if-none-match")
if inm and inm == etag:
# Strip content headers on 304.
for h in ("Content-Length", "content-length", "Content-Type", "content-type"):
headers.pop(h, None)
return Response(status_code=304, headers=headers)
return Response(content=body, status_code=200, headers=headers, media_type=response.media_type)
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
"""Limit request body size to prevent DoS attacks."""
@@ -138,26 +282,30 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan - startup and shutdown events."""
# Startup: check schema version and migrate if needed
print("Starting up: Checking database schema...")
check_and_migrate_if_needed()
print("Loading school data from database...")
global _sitemap_xml
print("Loading school data from marts...")
df = load_school_data()
if df.empty:
print("Warning: No data in database. Check CSV files in data/ folder.")
print("Warning: No data in marts. Run the annual EES pipeline to populate KS2 data.")
else:
print(f"Data loaded successfully: {len(df)} records.")
# Pre-compute the latest-year snapshot so the first search request is fast
await asyncio.to_thread(load_latest_school_data)
try:
_sitemap_xml = build_sitemap()
n = _sitemap_xml.count("<url>")
print(f"Sitemap built: {n} URLs.")
except Exception as e:
print(f"Warning: sitemap build failed on startup: {e}")
yield # Application runs here
yield
# Shutdown: cleanup if needed
print("Shutting down...")
app = FastAPI(
title="SchoolCompare API",
description="API for comparing primary school (KS2) performance data - schoolcompare.co.uk",
description="API for comparing primary and secondary school performance data - schoolcompare.co.uk",
version="2.0.0",
lifespan=lifespan,
# Disable docs in production for security
@@ -170,9 +318,12 @@ app = FastAPI(
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Security middleware (order matters - these run in reverse order)
# Middleware (Starlette runs the last-added middleware first on the way out,
# so list outermost-last: GZip wraps everything and compresses the final body).
app.add_middleware(CacheAndETagMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RequestSizeLimitMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=512)
# CORS middleware - restricted for production
app.add_middleware(
@@ -219,69 +370,93 @@ async def get_schools(
None, description="Filter by local authority", max_length=100
),
school_type: Optional[str] = Query(None, description="Filter by school type", max_length=100),
phase: Optional[str] = Query(None, description="Filter by phase: primary, secondary, all-through", max_length=50),
postcode: Optional[str] = Query(None, description="Search near postcode", max_length=10),
radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"),
radius: float = Query(5.0, ge=0.1, le=5, description="Search radius in miles"),
page: int = Query(1, ge=1, le=1000, description="Page number"),
page_size: int = Query(None, ge=1, le=100, description="Results per page"),
page_size: int = Query(25, ge=1, le=500, description="Results per page"),
gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50),
admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100),
has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3),
):
"""
Get list of unique primary schools with pagination.
Get list of schools with pagination.
Returns paginated results with total count for efficient loading.
Supports location-based search using postcode.
Supports location-based search using postcode and phase filtering.
"""
# Sanitize inputs
search = sanitize_search_input(search)
local_authority = sanitize_search_input(local_authority)
school_type = sanitize_search_input(school_type)
phase = sanitize_search_input(phase)
postcode = validate_postcode(postcode)
df = load_school_data()
# Load the pre-computed latest-year snapshot (cached after first request / startup).
# This avoids rebuilding the expensive groupby + prev-year merge on every search.
df_latest = load_latest_school_data()
if df.empty:
if df_latest.empty:
return {"schools": [], "total": 0, "page": page, "page_size": 0}
# Use configured default if not specified
if page_size is None:
page_size = settings.default_page_size
# Get unique schools (latest year data for each)
latest_year = df.groupby("urn")["year"].max().reset_index()
df_latest = df.merge(latest_year, on=["urn", "year"])
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
# in the correct phase(s) rather than being invisible to both filters.
if phase:
phase_lower = phase.lower().replace("_", "-")
allowed = PHASE_GROUPS.get(phase_lower)
if allowed:
df_latest = df_latest[df_latest["phase"].str.lower().isin(allowed)]
# Calculate trend by comparing to previous year
# Get second-latest year for each school
df_sorted = df.sort_values(["urn", "year"], ascending=[True, False])
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
)
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
# Secondary-specific filters (after phase filter)
if gender:
df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
if admissions_policy:
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
# GIAS OfficialSixthForm flag (dim_school.has_sixth_form). NULL (flag not
# yet populated by the pipeline) is treated as "no sixth form".
if has_sixth_form in ("yes", "no"):
if "has_sixth_form" in df_latest.columns:
flag = df_latest["has_sixth_form"].eq(True)
else: # Defensive fallback only — data_loader now always synthesizes
# has_sixth_form as NULL when the DB predates the pipeline re-run,
# so this branch shouldn't normally trigger. Falls back to age
# range if the column is somehow absent anyway.
flag = df_latest["age_range"].str.contains("18", na=False)
df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]
# Include key result metrics for display on cards
location_cols = ["latitude", "longitude"]
result_cols = [
"phase",
"year",
"rwm_expected_pct",
"rwm_high_pct",
"prev_rwm_expected_pct",
"prev_attainment_8_score",
"reading_expected_pct",
"writing_expected_pct",
"maths_expected_pct",
"total_pupils",
"attainment_8_score",
"english_maths_standard_pass_pct",
]
available_cols = [
c
for c in SCHOOL_COLUMNS + location_cols + result_cols
if c in df_latest.columns
]
schools_df = df_latest[available_cols].drop_duplicates(subset=["urn"])
# fact_performance guarantees one row per (urn, year); df_latest has one row per urn.
schools_df = df_latest[available_cols]
# Location-based search (uses pre-geocoded data from database)
search_coords = None
if postcode:
coords = geocode_single_postcode(postcode)
# Offload the synchronous HTTP call to a thread so the event loop stays free
coords = await asyncio.to_thread(geocode_single_postcode, postcode)
if coords:
search_coords = coords
schools_df = schools_df.copy()
@@ -321,14 +496,18 @@ async def get_schools(
# Apply filters
if search:
ts_urns = search_schools_typesense(search)
if ts_urns:
urn_order = {urn: i for i, urn in enumerate(ts_urns)}
schools_df = schools_df[schools_df["urn"].isin(set(ts_urns))].copy()
schools_df["_ts_rank"] = schools_df["urn"].map(urn_order)
schools_df = schools_df.sort_values("_ts_rank").drop(columns=["_ts_rank"])
else:
# Fallback: Typesense unavailable, use substring match
search_lower = search.lower()
mask = (
schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
)
mask = schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
if "address" in schools_df.columns:
mask = mask | schools_df["address"].str.lower().str.contains(
search_lower, na=False
)
mask = mask | schools_df["address"].str.lower().str.contains(search_lower, na=False)
schools_df = schools_df[mask]
if local_authority:
@@ -341,6 +520,18 @@ async def get_schools(
schools_df["school_type"].str.lower() == school_type.lower()
]
# Compute result-scoped filter values (before pagination).
# Gender and admissions are secondary-only filters — scope them to schools
# with KS4 data so they don't appear for purely primary result sets.
_sec_mask = schools_df["attainment_8_score"].notna() if "attainment_8_score" in schools_df.columns else pd.Series(False, index=schools_df.index)
result_filters = {
"local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [],
"school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [],
"phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [],
"genders": clean_filter_values(schools_df.loc[_sec_mask, "gender"]) if "gender" in schools_df.columns and _sec_mask.any() else [],
"admissions_policies": clean_filter_values(schools_df.loc[_sec_mask, "admissions_policy"]) if "admissions_policy" in schools_df.columns and _sec_mask.any() else [],
}
# Pagination
total = len(schools_df)
start_idx = (page - 1) * page_size
@@ -353,6 +544,7 @@ async def get_schools(
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
"result_filters": result_filters,
"location_info": {
"postcode": postcode,
"radius": radius * 1.60934, # Convert miles to km for frontend display
@@ -366,7 +558,7 @@ async def get_schools(
@app.get("/api/schools/{urn}")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_school_details(request: Request, urn: int):
"""Get detailed KS2 data for a specific primary school across all years."""
"""Get detailed performance data for a specific school across all years."""
# Validate URN range (UK school URNs are 6 digits)
if not (100000 <= urn <= 999999):
raise HTTPException(status_code=400, detail="Invalid URN format")
@@ -387,7 +579,7 @@ async def get_school_details(request: Request, urn: int):
# Get latest info for the school
latest = school_data.iloc[-1]
# Fetch supplementary data (Ofsted, Parent View, admissions, etc.)
# Fetch supplementary data (Ofsted, admissions, etc.)
from .database import SessionLocal
supplementary = {}
try:
@@ -397,8 +589,13 @@ async def get_school_details(request: Request, urn: int):
except Exception:
pass
return {
"school_info": {
# Schools with no performance rows (post-16 institutions, PRUs, new
# schools) carry NaN in every LEFT-JOINed numeric column; NaN reaching
# JSONResponse raises ValueError, so school_info needs the same
# conversion yearly_data gets from clean_for_json.
school_info = {
k: convert_to_native(v)
for k, v in {
"urn": urn,
"school_name": latest.get("school_name", ""),
"local_authority": latest.get("local_authority", ""),
@@ -406,22 +603,29 @@ async def get_school_details(request: Request, urn: int):
"address": latest.get("address", ""),
"religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""),
"has_sixth_form": latest.get("has_sixth_form"),
"status": latest.get("status"),
"latitude": latest.get("latitude"),
"longitude": latest.get("longitude"),
"phase": "Primary",
"phase": latest.get("phase"),
# GIAS fields
"website": latest.get("website"),
"headteacher_name": latest.get("headteacher_name"),
"capacity": latest.get("capacity"),
"total_pupils": latest.get("gias_total_pupils"),
"trust_name": latest.get("trust_name"),
"gender": latest.get("gender"),
},
}.items()
}
return {
"school_info": school_info,
"yearly_data": clean_for_json(school_data),
# Supplementary data (null if not yet populated by Kestra)
"ofsted": supplementary.get("ofsted"),
"parent_view": supplementary.get("parent_view"),
"census": supplementary.get("census"),
"admissions": supplementary.get("admissions"),
"admissions_history": supplementary.get("admissions_history") or [],
"sen_detail": supplementary.get("sen_detail"),
"phonics": supplementary.get("phonics"),
"deprivation": supplementary.get("deprivation"),
@@ -435,7 +639,7 @@ async def compare_schools(
request: Request,
urns: str = Query(..., description="Comma-separated URNs", max_length=100)
):
"""Compare multiple primary schools side by side."""
"""Compare multiple schools side by side."""
df = load_school_data()
if df.empty:
@@ -468,7 +672,11 @@ async def compare_schools(
"urn": urn,
"school_name": latest.get("school_name", ""),
"local_authority": latest.get("local_authority", ""),
"school_type": latest.get("school_type", ""),
"address": latest.get("address", ""),
"phase": latest.get("phase", ""),
"attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None,
"rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None,
},
"yearly_data": clean_for_json(school_data),
}
@@ -489,10 +697,132 @@ async def get_filter_options(request: Request):
"years": [],
}
# Phases: return values from data, ordered sensibly
phases = clean_filter_values(df["phase"]) if "phase" in df.columns else []
secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else []
admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) if "admissions_policy" in secondary_df.columns else []
return {
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
"local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [],
"school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [],
"years": sorted(df["year"].dropna().unique().tolist()),
"phases": phases,
"genders": genders,
"admissions_policies": admissions_policies,
}
@app.get("/api/la-averages")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_la_averages(request: Request):
"""Get per-LA average Attainment 8 score for secondary schools in the latest year."""
df = load_school_data()
if df.empty:
return {"year": 0, "secondary": {"attainment_8_by_la": {}}}
latest_year = int(df["year"].max())
sec_df = df[(df["year"] == latest_year) & df["attainment_8_score"].notna()]
la_avg = sec_df.groupby("local_authority")["attainment_8_score"].mean().round(1).to_dict()
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
@app.get("/api/national-averages")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_national_averages(request: Request):
"""
Compute national average for each metric from the latest data year.
Returns separate averages for primary (KS2) and secondary (KS4) schools.
Values are derived from the loaded DataFrame so they automatically
stay current when new data is loaded.
"""
df = load_school_data()
if df.empty:
return {"primary": {}, "secondary": {}}
ks2_metrics = [
"rwm_expected_pct", "rwm_high_pct",
"reading_expected_pct", "writing_expected_pct", "maths_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",
]
def _means(sub_df, 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)
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 .database import SessionLocal
from .models import Ks2NationalAverage
by_year = []
try:
db = SessionLocal()
nat_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
# Build a lookup of computed secondary averages per year as fallback
secondary_by_year = {}
for yr in sorted(df["year"].dropna().unique()):
yr = int(yr)
df_yr = df[df["year"] == yr]
secondary_by_year[yr] = _means(
df_yr[df_yr["attainment_8_score"].notna()], ks4_metrics
)
# Merge: official KS2 figures + computed KS4 figures per year
ks2_years = {r.year for r in nat_rows}
all_years = sorted(ks2_years | set(secondary_by_year.keys()))
nat_lookup = {r.year: r for r in nat_rows}
for yr in all_years:
primary_yr: dict = {}
if yr in nat_lookup:
r = nat_lookup[yr]
for col in ks2_metrics:
val = getattr(r, col, None)
if val is not None:
primary_yr[col] = val
by_year.append({
"year": yr,
"primary": primary_yr,
"secondary": secondary_by_year.get(yr, {}),
})
finally:
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
return {
"year": latest_year,
"primary": latest_primary,
"secondary": latest_secondary,
"by_year": by_year,
}
@@ -500,7 +830,7 @@ async def get_filter_options(request: Request):
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_available_metrics(request: Request):
"""
Get list of available KS2 performance metrics for primary schools.
Get list of available performance metrics for schools.
This is the single source of truth for metric definitions.
Frontend should consume this to avoid duplication.
@@ -519,16 +849,22 @@ async def get_available_metrics(request: Request):
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_rankings(
request: Request,
metric: str = Query("rwm_expected_pct", description="KS2 metric to rank by", max_length=50),
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
year: Optional[int] = Query(
None, description="Specific year (defaults to most recent)", ge=2000, le=2100
None,
description="Academic year code, e.g. 201819 (defaults to most recent)",
ge=2000,
le=210100,
),
limit: int = Query(20, ge=1, le=100, description="Number of schools to return"),
local_authority: Optional[str] = Query(
None, description="Filter by local authority", max_length=100
),
phase: Optional[str] = Query(
None, description="Filter by phase: primary or secondary", max_length=20
),
):
"""Get primary school rankings by a specific KS2 metric."""
"""Get school rankings by a specific metric."""
# Sanitize local authority input
local_authority = sanitize_search_input(local_authority)
@@ -556,6 +892,12 @@ async def get_rankings(
if local_authority:
df = df[df["local_authority"].str.lower() == local_authority.lower()]
# Filter by phase
if phase == "primary" and "rwm_expected_pct" in df.columns:
df = df[df["rwm_expected_pct"].notna()]
elif phase == "secondary" and "attainment_8_score" in df.columns:
df = df[df["attainment_8_score"].notna()]
# Sort and rank (exclude rows with no data for this metric)
df = df.dropna(subset=[metric])
total = len(df)
@@ -565,7 +907,12 @@ async def get_rankings(
# Return only relevant fields for rankings
available_cols = [c for c in RANKING_COLUMNS if c in df.columns]
df = df[available_cols]
df = df[available_cols].copy()
# Surface the requested metric under a stable `value` key so the
# frontend doesn't need to know each metric's column name. The raw
# metric column is also kept in the row for callers that want it.
df["value"] = df[metric]
return {
"metric": metric,
@@ -585,7 +932,7 @@ async def get_data_info(request: Request):
if db_info["total_schools"] == 0:
return {
"status": "no_data",
"message": "No data in database. Run the migration script: python scripts/migrate_csv_to_db.py",
"message": "No data in marts. Run the annual EES pipeline to load KS2 data.",
"data_source": "PostgreSQL",
}
@@ -599,10 +946,10 @@ async def get_data_info(request: Request):
"data_source": "PostgreSQL",
}
years = [int(y) for y in sorted(df["year"].unique())]
years = [int(y) for y in sorted(df["year"].dropna().unique())]
schools_per_year = {
str(int(k)): int(v)
for k, v in df.groupby("year")["urn"].nunique().to_dict().items()
for k, v in df.dropna(subset=["year"]).groupby("year")["urn"].nunique().to_dict().items()
}
la_counts = {
str(k): int(v)
@@ -631,60 +978,11 @@ async def reload_data(
Requires X-API-Key header with valid admin API key.
"""
clear_cache()
load_school_data()
await asyncio.to_thread(load_school_data)
await asyncio.to_thread(load_latest_school_data)
return {"status": "reloaded"}
_reimport_status: dict = {"running": False, "done": False, "error": None}
@app.post("/api/admin/reimport-ks2")
@limiter.limit("2/minute")
async def reimport_ks2(
request: Request,
geocode: bool = True,
_: bool = Depends(verify_admin_api_key)
):
"""
Start a full KS2 CSV migration in the background and return immediately.
Poll GET /api/admin/reimport-ks2/status to check progress.
Pass ?geocode=false to skip postcode → lat/lng resolution.
Requires X-API-Key header with valid admin API key.
"""
global _reimport_status
if _reimport_status["running"]:
return {"status": "already_running"}
_reimport_status = {"running": True, "done": False, "error": None}
def _run():
global _reimport_status
try:
success = run_full_migration(geocode=geocode)
if not success:
_reimport_status = {"running": False, "done": False, "error": "No CSV data found"}
return
clear_cache()
load_school_data()
_reimport_status = {"running": False, "done": True, "error": None}
except Exception as exc:
_reimport_status = {"running": False, "done": False, "error": str(exc)}
import threading
threading.Thread(target=_run, daemon=True).start()
return {"status": "started"}
@app.get("/api/admin/reimport-ks2/status")
async def reimport_ks2_status(
request: Request,
_: bool = Depends(verify_admin_api_key)
):
"""Poll this endpoint to check reimport progress."""
s = _reimport_status
if s["error"]:
raise HTTPException(status_code=500, detail=s["error"])
return {"running": s["running"], "done": s["done"]}
# =============================================================================
@@ -707,7 +1005,26 @@ async def robots_txt():
@app.get("/sitemap.xml")
async def sitemap_xml():
"""Serve sitemap.xml for search engine indexing."""
return FileResponse(settings.frontend_dir / "sitemap.xml", media_type="application/xml")
global _sitemap_xml
if _sitemap_xml is None:
try:
_sitemap_xml = build_sitemap()
except Exception as e:
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
return Response(content=_sitemap_xml, media_type="application/xml")
@app.post("/api/admin/regenerate-sitemap")
@limiter.limit("10/minute")
async def regenerate_sitemap(
request: Request,
_: bool = Depends(verify_admin_api_key),
):
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
global _sitemap_xml
_sitemap_xml = build_sitemap()
n = _sitemap_xml.count("<url>")
return {"status": "ok", "urls": n}
# Mount static files directly (must be after all routes to avoid catching API calls)
+4
View File
@@ -38,6 +38,10 @@ class Settings(BaseSettings):
rate_limit_burst: int = 10 # Allow burst of requests
max_request_size: int = 1024 * 1024 # 1MB max request size
# Typesense
typesense_url: str = "http://localhost:8108"
typesense_api_key: str = ""
# Analytics
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
+482 -531
View File
File diff suppressed because it is too large Load Diff
+9 -113
View File
@@ -1,36 +1,31 @@
"""
Database connection setup using SQLAlchemy.
The schema is managed by dbt — the backend only reads from marts.* tables.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker, declarative_base
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import settings
# Create engine
engine = create_engine(
settings.database_url,
pool_size=10,
max_overflow=20,
pool_pre_ping=True, # Verify connections before use
echo=False, # Set to True for SQL debugging
pool_pre_ping=True,
pool_recycle=1800, # recycle connections every 30 min to avoid stale TCP
echo=False,
)
# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
def get_db():
"""
Dependency for FastAPI routes to get a database session.
"""
"""Dependency for FastAPI routes."""
db = SessionLocal()
try:
yield db
@@ -40,108 +35,9 @@ def get_db():
@contextmanager
def get_db_session():
"""
Context manager for database sessions.
Use in non-FastAPI contexts (scripts, etc).
"""
"""Context manager for non-FastAPI contexts (read-only)."""
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
def init_db():
"""
Initialize database - create all tables.
"""
Base.metadata.create_all(bind=engine)
def drop_db():
"""
Drop all tables - use with caution!
"""
Base.metadata.drop_all(bind=engine)
def get_db_schema_version() -> Optional[int]:
"""
Get the current schema version from the database.
Returns None if table doesn't exist or no version is set.
"""
from .models import SchemaVersion # Import here to avoid circular imports
# Check if schema_version table exists
inspector = inspect(engine)
if "schema_version" not in inspector.get_table_names():
return None
try:
with get_db_session() as db:
row = db.query(SchemaVersion).first()
return row.version if row else None
except Exception:
return None
def set_db_schema_version(version: int):
"""
Set/update the schema version in the database.
Creates the row if it doesn't exist.
"""
from .models import SchemaVersion
with get_db_session() as db:
row = db.query(SchemaVersion).first()
if row:
row.version = version
row.migrated_at = datetime.utcnow()
else:
db.add(SchemaVersion(id=1, version=version, migrated_at=datetime.utcnow()))
def check_and_migrate_if_needed():
"""
Check schema version and run migration if needed.
Called during application startup.
"""
from .version import SCHEMA_VERSION
from .migration import run_full_migration
db_version = get_db_schema_version()
if db_version == SCHEMA_VERSION:
print(f"Schema version {SCHEMA_VERSION} matches. Fast startup.")
# Still ensure tables exist (they should if version matches)
init_db()
return
if db_version is None:
print(f"No schema version found. Running initial migration (v{SCHEMA_VERSION})...")
else:
print(f"Schema mismatch: DB has v{db_version}, code expects v{SCHEMA_VERSION}")
print("Running full migration...")
try:
# Set schema version BEFORE migration so a crash mid-migration
# doesn't cause an infinite re-migration loop on every restart.
init_db()
set_db_schema_version(SCHEMA_VERSION)
success = run_full_migration(geocode=False)
if success:
print(f"Migration complete. Schema version {SCHEMA_VERSION}.")
else:
print("Warning: Migration completed but no data was imported.")
except Exception as e:
print(f"FATAL: Migration failed: {e}")
print("Application cannot start. Please check database and CSV files.")
raise
+152
View File
@@ -0,0 +1,152 @@
"""GIAS code -> name dictionaries.
GENERATED by pipeline/scripts/generate_gias_codes.py from the GIAS bulk CSV
— do not edit by hand; rerun the script when the dbt drift test warns.
The canonical file is backend/gias_codes.py; pipeline/scripts/gias_codes.py
must be byte-identical (enforced by backend/tests/test_gias_codes.py).
"""
from __future__ import annotations
import logging
import math
logger = logging.getLogger(__name__)
SCHOOL_TYPE: dict[int, str] = {
1: "Community school",
2: "Voluntary aided school",
3: "Voluntary controlled school",
5: "Foundation school",
6: "City technology college",
7: "Community special school",
8: "Non-maintained special school",
10: "Other independent special school",
11: "Other independent school",
12: "Foundation special school",
14: "Pupil referral unit",
15: "Local authority nursery school",
18: "Further education",
24: "Secure units",
25: "Offshore schools",
26: "Service children's education",
27: "Miscellaneous",
28: "Academy sponsor led",
29: "Higher education institutions",
30: "Welsh establishment",
31: "Sixth form centres",
32: "Special post 16 institution",
33: "Academy special sponsor led",
34: "Academy converter",
35: "Free schools",
36: "Free schools special",
37: "British schools overseas",
38: "Free schools alternative provision",
39: "Free schools 16 to 19",
40: "University technical college",
41: "Studio schools",
42: "Academy alternative provision converter",
43: "Academy alternative provision sponsor led",
44: "Academy special converter",
45: "Academy 16-19 converter",
46: "Academy 16 to 19 sponsor led",
49: "Online provider",
56: "Institution funded by other government department",
57: "Academy secure 16 to 19",
}
ESTABLISHMENT_STATUS: dict[int, str] = {
1: "Open",
2: "Closed",
3: "Open, but proposed to close",
4: "Proposed to open",
}
PHASE_OF_EDUCATION: dict[int, str] = {
0: "Not applicable",
1: "Nursery",
2: "Primary",
3: "Middle deemed primary",
4: "Secondary",
5: "Middle deemed secondary",
6: "16 plus",
7: "All-through",
}
OFFICIAL_SIXTH_FORM: dict[int, str] = {
0: "Not applicable",
1: "Has a sixth form",
2: "Does not have a sixth form",
}
RELIGIOUS_CHARACTER: dict[int, str] = {
0: "Does not apply",
2: "Church of England",
3: "Roman Catholic",
4: "Methodist",
5: "Jewish",
6: "None",
7: "Muslim",
8: "Seventh Day Adventist",
9: "Church of England/Methodist",
10: "Methodist/Church of England",
11: "Church of England/Roman Catholic",
12: "Church of England/United Reformed Church",
13: "Roman Catholic/Church of England",
14: "Quaker",
15: "Christian",
16: "United Reformed Church",
17: "Congregational Church",
18: "Free Church",
19: "Church of England/Free Church",
20: "Church of England/Christian",
21: "Sikh",
22: "Greek Orthodox",
24: "Buddhist",
25: "Hindu",
26: "Moravian",
28: "Inter- / non- denominational",
29: "Multi-faith",
30: "Church of England/Methodist/United Reform Church/Baptist",
31: "Anglican",
32: "Anglican/Christian",
33: "Anglican/Evangelical",
34: "Anglican/Church of England",
35: "Catholic",
36: "Charadi Jewish",
37: "Christian/Evangelical",
38: "Christian Science",
39: "Christian/Methodist",
40: "Christian/non-denominational",
41: "Church of England/Evangelical",
42: "Islam",
43: "Orthodox Jewish",
44: "Plymouth Brethren Christian Church",
45: "Protestant",
46: "Protestant/Evangelical",
47: "Reformed Baptist",
48: "Roman Catholic/Anglican",
49: "Sunni Deobandi",
}
ADMISSIONS_POLICY: dict[int, str] = {
0: "Not applicable",
2: "Selective",
4: "Non-selective",
}
def translate(code, mapping: dict[int, str]) -> str | None:
"""Translate a GIAS code to its display name.
None/NaN -> None (column absent or suppressed). Unknown codes degrade to
"Unknown (<code>)" with a warning so a new DfE value never blanks the UI.
"""
if code is None or (isinstance(code, float) and math.isnan(code)):
return None
code = int(code)
if code not in mapping:
logger.warning("Unknown GIAS code %s (not in dictionary)", code)
return f"Unknown ({code})"
return mapping[code]
+22
View File
@@ -433,6 +433,25 @@ def _apply_schema_alterations():
conn.commit()
def _apply_schema_drops():
"""
Drop tables retired from the schema. Idempotent (DROP … IF EXISTS), so it's
safe to run on every migration. Add entries here when a model is removed.
"""
drops = [
# v6: Ofsted Parent View feature removed
"DROP TABLE IF EXISTS marts.fact_parent_view CASCADE",
]
from sqlalchemy import text as sa_text
with engine.connect() as conn:
for stmt in drops:
try:
conn.execute(sa_text(stmt))
except Exception as e:
print(f" Warning: drop skipped ({e})")
conn.commit()
def run_full_migration(geocode: bool = False) -> bool:
"""
Run a complete migration: drop all tables and reimport from CSV.
@@ -479,6 +498,9 @@ def run_full_migration(geocode: bool = False) -> bool:
print("Applying column additions to supplementary tables...")
_apply_schema_alterations()
print("Dropping retired tables...")
_apply_schema_drops()
print("\nLoading CSV data...")
df = load_csv_data(settings.data_dir)
+161 -330
View File
@@ -1,408 +1,239 @@
"""
SQLAlchemy database models for school data.
Normalized schema with separate tables for schools and yearly results.
SQLAlchemy models — all tables live in the marts schema, built by dbt.
Read-only: the pipeline writes to these tables; the backend only reads.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, Boolean, Date, Text, Index
from sqlalchemy import (
Column, Integer, String, Float, ForeignKey, Index, UniqueConstraint,
Text, Boolean, DateTime, Date
)
from sqlalchemy.orm import relationship
from .database import Base
MARTS = {"schema": "marts"}
class School(Base):
"""
Core school information - relatively static data.
"""
__tablename__ = "schools"
id = Column(Integer, primary_key=True, autoincrement=True)
urn = Column(Integer, unique=True, nullable=False, index=True)
class DimSchool(Base):
"""Canonical school dimension — one row per active URN."""
__tablename__ = "dim_school"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
school_name = Column(String(255), nullable=False)
local_authority = Column(String(100))
local_authority_code = Column(Integer)
school_type = Column(String(100))
school_type_code = Column(String(10))
religious_denomination = Column(String(100))
phase_code = Column(Integer)
school_type_code = Column(Integer)
academy_trust_name = Column(String(255))
academy_trust_uid = Column(String(20))
religious_character_code = Column(Integer)
gender = Column(String(20))
age_range = Column(String(20))
has_sixth_form = Column(Boolean)
capacity = Column(Integer)
total_pupils = Column(Integer)
headteacher_name = Column(String(200))
website = Column(String(255))
telephone = Column(String(30))
status_code = Column(Integer)
nursery_provision = Column(Boolean)
admissions_policy_code = Column(Integer)
# Denormalised Ofsted summary (updated by monthly pipeline)
ofsted_grade = Column(Integer)
ofsted_date = Column(Date)
ofsted_framework = Column(String(20))
# Address
address1 = Column(String(255))
address2 = Column(String(255))
class DimLocation(Base):
"""School location — address, lat/lng from easting/northing (BNG→WGS84)."""
__tablename__ = "dim_location"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
address_line1 = Column(String(255))
address_line2 = Column(String(255))
town = Column(String(100))
postcode = Column(String(20), index=True)
# Geocoding (cached)
county = Column(String(100))
postcode = Column(String(20))
local_authority_code = Column(Integer)
local_authority_name = Column(String(100))
parliamentary_constituency = Column(String(100))
urban_rural = Column(String(50))
easting = Column(Integer)
northing = Column(Integer)
latitude = Column(Float)
longitude = Column(Float)
# GIAS enrichment fields
website = Column(String(255))
headteacher_name = Column(String(200))
capacity = Column(Integer)
trust_name = Column(String(255))
trust_uid = Column(String(20))
gender = Column(String(20)) # Mixed / Girls / Boys
nursery_provision = Column(Boolean)
# Relationships
results = relationship("SchoolResult", back_populates="school", cascade="all, delete-orphan")
def __repr__(self):
return f"<School(urn={self.urn}, name='{self.school_name}')>"
@property
def address(self):
"""Combine address fields into single string."""
parts = [self.address1, self.address2, self.town, self.postcode]
return ", ".join(p for p in parts if p)
# geom is a PostGIS geometry — not mapped to SQLAlchemy (accessed via raw SQL)
class SchoolResult(Base):
"""
Yearly KS2 results for a school.
Each school can have multiple years of results.
"""
__tablename__ = "school_results"
class KS2Performance(Base):
"""KS2 attainment — one row per URN per year (includes predecessor data)."""
__tablename__ = "fact_ks2_performance"
__table_args__ = (
Index("ix_ks2_urn_year", "urn", "year"),
MARTS,
)
id = Column(Integer, primary_key=True, autoincrement=True)
school_id = Column(Integer, ForeignKey("schools.id", ondelete="CASCADE"), nullable=False)
year = Column(Integer, nullable=False, index=True)
# Pupil numbers
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
source_urn = Column(Integer)
total_pupils = Column(Integer)
eligible_pupils = Column(Integer)
# Core KS2 metrics - Expected Standard
# Core attainment
rwm_expected_pct = Column(Float)
reading_expected_pct = Column(Float)
writing_expected_pct = Column(Float)
maths_expected_pct = Column(Float)
gps_expected_pct = Column(Float)
science_expected_pct = Column(Float)
# Higher Standard
rwm_high_pct = Column(Float)
reading_expected_pct = Column(Float)
reading_high_pct = Column(Float)
writing_high_pct = Column(Float)
maths_high_pct = Column(Float)
gps_high_pct = Column(Float)
# Progress Scores
reading_progress = Column(Float)
writing_progress = Column(Float)
maths_progress = Column(Float)
# Average Scores
reading_avg_score = Column(Float)
reading_progress = Column(Float)
writing_expected_pct = Column(Float)
writing_high_pct = Column(Float)
writing_progress = Column(Float)
maths_expected_pct = Column(Float)
maths_high_pct = Column(Float)
maths_avg_score = Column(Float)
maths_progress = Column(Float)
gps_expected_pct = Column(Float)
gps_high_pct = Column(Float)
gps_avg_score = Column(Float)
# School Context
science_expected_pct = Column(Float)
# Absence
reading_absence_pct = Column(Float)
writing_absence_pct = Column(Float)
maths_absence_pct = Column(Float)
gps_absence_pct = Column(Float)
science_absence_pct = Column(Float)
# Gender
rwm_expected_boys_pct = Column(Float)
rwm_high_boys_pct = Column(Float)
rwm_expected_girls_pct = Column(Float)
rwm_high_girls_pct = Column(Float)
# Disadvantaged
rwm_expected_disadvantaged_pct = Column(Float)
rwm_expected_non_disadvantaged_pct = Column(Float)
disadvantaged_gap = Column(Float)
# Context
disadvantaged_pct = Column(Float)
eal_pct = Column(Float)
sen_support_pct = Column(Float)
sen_ehcp_pct = Column(Float)
stability_pct = Column(Float)
# Pupil Absence from Tests
reading_absence_pct = Column(Float)
gps_absence_pct = Column(Float)
maths_absence_pct = Column(Float)
writing_absence_pct = Column(Float)
science_absence_pct = Column(Float)
# Gender Breakdown
rwm_expected_boys_pct = Column(Float)
rwm_expected_girls_pct = Column(Float)
rwm_high_boys_pct = Column(Float)
rwm_high_girls_pct = Column(Float)
# Disadvantaged Performance
rwm_expected_disadvantaged_pct = Column(Float)
rwm_expected_non_disadvantaged_pct = Column(Float)
disadvantaged_gap = Column(Float)
# 3-Year Averages
rwm_expected_3yr_pct = Column(Float)
reading_avg_3yr = Column(Float)
maths_avg_3yr = Column(Float)
# Relationship
school = relationship("School", back_populates="results")
# Constraints
class FactOfstedInspection(Base):
"""Full Ofsted inspection history — one row per inspection."""
__tablename__ = "fact_ofsted_inspection"
__table_args__ = (
UniqueConstraint('school_id', 'year', name='uq_school_year'),
Index('ix_school_results_school_year', 'school_id', 'year'),
Index("ix_ofsted_urn_date", "urn", "inspection_date"),
MARTS,
)
def __repr__(self):
return f"<SchoolResult(school_id={self.school_id}, year={self.year})>"
class SchemaVersion(Base):
"""
Tracks database schema version for automatic migrations.
Single-row table that stores the current schema version.
"""
__tablename__ = "schema_version"
id = Column(Integer, primary_key=True, default=1)
version = Column(Integer, nullable=False)
migrated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<SchemaVersion(version={self.version}, migrated_at={self.migrated_at})>"
# ---------------------------------------------------------------------------
# Supplementary data tables (populated by the Kestra data integrator)
# ---------------------------------------------------------------------------
class OfstedInspection(Base):
"""Latest Ofsted inspection judgement per school."""
__tablename__ = "ofsted_inspections"
urn = Column(Integer, primary_key=True)
inspection_date = Column(Date)
publication_date = Column(Date)
inspection_type = Column(String(100)) # Section 5 / Section 8 etc.
# Which inspection framework was used: 'OEIF' or 'ReportCard'
inspection_date = Column(Date, primary_key=True)
inspection_type = Column(String(100))
framework = Column(String(20))
# --- OEIF grades (old framework, pre-Nov 2025) ---
# 1=Outstanding 2=Good 3=Requires improvement 4=Inadequate
overall_effectiveness = Column(Integer)
quality_of_education = Column(Integer)
behaviour_attitudes = Column(Integer)
personal_development = Column(Integer)
leadership_management = Column(Integer)
early_years_provision = Column(Integer) # nullable — not all schools
previous_overall = Column(Integer) # for trend display
# --- Report Card grades (new framework, from Nov 2025) ---
# 1=Exceptional 2=Strong 3=Expected standard 4=Needs attention 5=Urgent improvement
rc_safeguarding_met = Column(Boolean) # True=Met, False=Not met
early_years_provision = Column(Integer)
sixth_form_provision = Column(Integer)
# Ungraded (Section 8) inspection: raw outcome text and the grade parsed from
# it (fallback for schools with no graded overall effectiveness).
ungraded_outcome = Column(String(100))
ungraded_grade = Column(Integer)
rc_safeguarding_met = Column(Boolean)
rc_inclusion = Column(Integer)
rc_curriculum_teaching = Column(Integer)
rc_achievement = Column(Integer)
rc_attendance_behaviour = Column(Integer)
rc_personal_development = Column(Integer)
rc_leadership_governance = Column(Integer)
rc_early_years = Column(Integer) # nullable — not all schools
rc_sixth_form = Column(Integer) # nullable — secondary only
def __repr__(self):
return f"<OfstedInspection(urn={self.urn}, framework={self.framework}, overall={self.overall_effectiveness})>"
rc_early_years = Column(Integer)
rc_sixth_form = Column(Integer)
report_url = Column(Text)
class OfstedParentView(Base):
"""Ofsted Parent View survey — latest per school. 14 questions, % saying Yes."""
__tablename__ = "ofsted_parent_view"
urn = Column(Integer, primary_key=True)
survey_date = Column(Date)
total_responses = Column(Integer)
q_happy_pct = Column(Float) # My child is happy at this school
q_safe_pct = Column(Float) # My child feels safe at this school
q_bullying_pct = Column(Float) # School deals with bullying well
q_communication_pct = Column(Float) # School keeps me informed
q_progress_pct = Column(Float) # My child does well / good progress
q_teaching_pct = Column(Float) # Teaching is good
q_information_pct = Column(Float) # I receive valuable info about progress
q_curriculum_pct = Column(Float) # Broad range of subjects taught
q_future_pct = Column(Float) # Prepares child well for the future
q_leadership_pct = Column(Float) # Led and managed effectively
q_wellbeing_pct = Column(Float) # Supports wider personal development
q_behaviour_pct = Column(Float) # Pupils are well behaved
q_recommend_pct = Column(Float) # I would recommend this school
q_sen_pct = Column(Float) # Good information about child's SEN (where applicable)
def __repr__(self):
return f"<OfstedParentView(urn={self.urn}, responses={self.total_responses})>"
class SchoolCensus(Base):
"""Annual school census snapshot — class sizes and ethnicity breakdown."""
__tablename__ = "school_census"
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
class_size_avg = Column(Float)
ethnicity_white_pct = Column(Float)
ethnicity_asian_pct = Column(Float)
ethnicity_black_pct = Column(Float)
ethnicity_mixed_pct = Column(Float)
ethnicity_other_pct = Column(Float)
class FactAdmissions(Base):
"""School admissions — one row per URN per year."""
__tablename__ = "fact_admissions"
__table_args__ = (
Index('ix_school_census_urn_year', 'urn', 'year'),
Index("ix_admissions_urn_year", "urn", "year"),
MARTS,
)
def __repr__(self):
return f"<SchoolCensus(urn={self.urn}, year={self.year})>"
class SchoolAdmissions(Base):
"""Annual admissions statistics per school."""
__tablename__ = "school_admissions"
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
published_admission_number = Column(Integer) # PAN
school_phase = Column(String(50))
places_offered = Column(Integer)
total_applications = Column(Integer)
first_preference_offers_pct = Column(Float) # % receiving 1st choice
first_preference_applications = Column(Integer)
first_preference_offers = Column(Integer)
first_preference_offer_pct = Column(Float)
oversubscription_ratio = Column(Float)
oversubscribed = Column(Boolean)
admissions_policy = Column(String(100))
class FactPupilCharacteristics(Base):
"""School pupil composition from EES census — one row per URN per year."""
__tablename__ = "fact_pupil_characteristics"
__table_args__ = (
Index('ix_school_admissions_urn_year', 'urn', 'year'),
Index("ix_pupil_chars_urn_year", "urn", "year"),
MARTS,
)
def __repr__(self):
return f"<SchoolAdmissions(urn={self.urn}, year={self.year})>"
class SenDetail(Base):
"""SEN primary need type breakdown — more granular than school_results context fields."""
__tablename__ = "sen_detail"
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
primary_need_speech_pct = Column(Float) # SLCN
primary_need_autism_pct = Column(Float) # ASD
primary_need_mld_pct = Column(Float) # Moderate learning difficulty
primary_need_spld_pct = Column(Float) # Specific learning difficulty (dyslexia etc.)
primary_need_semh_pct = Column(Float) # Social, emotional, mental health
primary_need_physical_pct = Column(Float) # Physical/sensory
primary_need_other_pct = Column(Float)
__table_args__ = (
Index('ix_sen_detail_urn_year', 'urn', 'year'),
)
def __repr__(self):
return f"<SenDetail(urn={self.urn}, year={self.year})>"
phase_type_grouping = Column(String(50))
total_pupils = Column(Integer)
female_pupils = Column(Integer)
male_pupils = Column(Integer)
fsm_pct = Column(Float)
eal_pct = Column(Float)
class Phonics(Base):
"""Phonics Screening Check pass rates."""
__tablename__ = "phonics"
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
year1_phonics_pct = Column(Float) # % reaching expected standard in Year 1
year2_phonics_pct = Column(Float) # % reaching standard in Year 2 (re-takers)
__table_args__ = (
Index('ix_phonics_urn_year', 'urn', 'year'),
)
def __repr__(self):
return f"<Phonics(urn={self.urn}, year={self.year})>"
class SchoolDeprivation(Base):
"""IDACI deprivation index — derived via postcode → LSOA lookup."""
__tablename__ = "school_deprivation"
class FactDeprivation(Base):
"""IDACI deprivation index — one row per URN."""
__tablename__ = "fact_deprivation"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
lsoa_code = Column(String(20))
idaci_score = Column(Float) # 01, higher = more deprived
idaci_decile = Column(Integer) # 1 = most deprived, 10 = least deprived
def __repr__(self):
return f"<SchoolDeprivation(urn={self.urn}, decile={self.idaci_decile})>"
idaci_score = Column(Float)
idaci_decile = Column(Integer)
class SchoolFinance(Base):
"""FBIT financial benchmarking data."""
__tablename__ = "school_finance"
class FactFinance(Base):
"""FBIT financial benchmarking — one row per URN per year."""
__tablename__ = "fact_finance"
__table_args__ = (
Index("ix_finance_urn_year", "urn", "year"),
MARTS,
)
urn = Column(Integer, primary_key=True)
year = Column(Integer, primary_key=True)
per_pupil_spend = Column(Float) # £ total expenditure per pupil
staff_cost_pct = Column(Float) # % of budget on all staff
teacher_cost_pct = Column(Float) # % on teachers specifically
per_pupil_spend = Column(Float)
staff_cost_pct = Column(Float)
teacher_cost_pct = Column(Float)
support_staff_cost_pct = Column(Float)
premises_cost_pct = Column(Float)
__table_args__ = (
Index('ix_school_finance_urn_year', 'urn', 'year'),
)
def __repr__(self):
return f"<SchoolFinance(urn={self.urn}, year={self.year})>"
# Mapping from CSV columns to model fields
SCHOOL_FIELD_MAPPING = {
'urn': 'urn',
'school_name': 'school_name',
'local_authority': 'local_authority',
'local_authority_code': 'local_authority_code',
'school_type': 'school_type',
'school_type_code': 'school_type_code',
'religious_denomination': 'religious_denomination',
'age_range': 'age_range',
'address1': 'address1',
'address2': 'address2',
'town': 'town',
'postcode': 'postcode',
}
RESULT_FIELD_MAPPING = {
'year': 'year',
'total_pupils': 'total_pupils',
'eligible_pupils': 'eligible_pupils',
# Expected Standard
'rwm_expected_pct': 'rwm_expected_pct',
'reading_expected_pct': 'reading_expected_pct',
'writing_expected_pct': 'writing_expected_pct',
'maths_expected_pct': 'maths_expected_pct',
'gps_expected_pct': 'gps_expected_pct',
'science_expected_pct': 'science_expected_pct',
# Higher Standard
'rwm_high_pct': 'rwm_high_pct',
'reading_high_pct': 'reading_high_pct',
'writing_high_pct': 'writing_high_pct',
'maths_high_pct': 'maths_high_pct',
'gps_high_pct': 'gps_high_pct',
# Progress
'reading_progress': 'reading_progress',
'writing_progress': 'writing_progress',
'maths_progress': 'maths_progress',
# Averages
'reading_avg_score': 'reading_avg_score',
'maths_avg_score': 'maths_avg_score',
'gps_avg_score': 'gps_avg_score',
# Context
'disadvantaged_pct': 'disadvantaged_pct',
'eal_pct': 'eal_pct',
'sen_support_pct': 'sen_support_pct',
'sen_ehcp_pct': 'sen_ehcp_pct',
'stability_pct': 'stability_pct',
# Absence
'reading_absence_pct': 'reading_absence_pct',
'gps_absence_pct': 'gps_absence_pct',
'maths_absence_pct': 'maths_absence_pct',
'writing_absence_pct': 'writing_absence_pct',
'science_absence_pct': 'science_absence_pct',
# Gender
'rwm_expected_boys_pct': 'rwm_expected_boys_pct',
'rwm_expected_girls_pct': 'rwm_expected_girls_pct',
'rwm_high_boys_pct': 'rwm_high_boys_pct',
'rwm_high_girls_pct': 'rwm_high_girls_pct',
# Disadvantaged
'rwm_expected_disadvantaged_pct': 'rwm_expected_disadvantaged_pct',
'rwm_expected_non_disadvantaged_pct': 'rwm_expected_non_disadvantaged_pct',
'disadvantaged_gap': 'disadvantaged_gap',
# 3-Year
'rwm_expected_3yr_pct': 'rwm_expected_3yr_pct',
'reading_avg_3yr': 'reading_avg_3yr',
'maths_avg_3yr': 'maths_avg_3yr',
}
class Ks2NationalAverage(Base):
"""Official DfE KS2 national headline averages — one row per academic year."""
__tablename__ = "fact_ks2_national_averages"
__table_args__ = MARTS
year = Column(Integer, primary_key=True)
rwm_expected_pct = Column(Float)
rwm_high_pct = Column(Float)
reading_expected_pct = Column(Float)
reading_high_pct = Column(Float)
reading_avg_score = Column(Float)
writing_expected_pct = Column(Float)
writing_gd_pct = Column(Float)
maths_expected_pct = Column(Float)
maths_high_pct = Column(Float)
maths_avg_score = Column(Float)
gps_expected_pct = Column(Float)
gps_high_pct = Column(Float)
gps_avg_score = Column(Float)
science_expected_pct = Column(Float)
+91 -10
View File
@@ -142,7 +142,7 @@ NULL_VALUES = ["SUPP", "NE", "NA", "NP", "NEW", "LOW", ""]
METRIC_DEFINITIONS = {
# Expected Standard
"rwm_expected_pct": {
"name": "RWM Combined %",
"name": "Reading, Writing & Maths Combined %",
"short_name": "RWM %",
"description": "% meeting expected standard in reading, writing and maths",
"type": "percentage",
@@ -185,9 +185,9 @@ METRIC_DEFINITIONS = {
},
# Higher Standard
"rwm_high_pct": {
"name": "RWM Combined Higher %",
"name": "Reading, Writing & Maths Combined Higher %",
"short_name": "RWM Higher %",
"description": "% achieving higher standard in RWM combined",
"description": "% achieving higher standard in reading, writing & maths combined",
"type": "percentage",
"category": "higher",
},
@@ -265,28 +265,28 @@ METRIC_DEFINITIONS = {
},
# Gender Performance
"rwm_expected_boys_pct": {
"name": "RWM Expected % (Boys)",
"name": "Reading, Writing & Maths Expected % (Boys)",
"short_name": "Boys RWM %",
"description": "% of boys meeting expected standard",
"type": "percentage",
"category": "gender",
},
"rwm_expected_girls_pct": {
"name": "RWM Expected % (Girls)",
"name": "Reading, Writing & Maths Expected % (Girls)",
"short_name": "Girls RWM %",
"description": "% of girls meeting expected standard",
"type": "percentage",
"category": "gender",
},
"rwm_high_boys_pct": {
"name": "RWM Higher % (Boys)",
"name": "Reading, Writing & Maths Higher % (Boys)",
"short_name": "Boys Higher %",
"description": "% of boys at higher standard",
"type": "percentage",
"category": "gender",
},
"rwm_high_girls_pct": {
"name": "RWM Higher % (Girls)",
"name": "Reading, Writing & Maths Higher % (Girls)",
"short_name": "Girls Higher %",
"description": "% of girls at higher standard",
"type": "percentage",
@@ -294,14 +294,14 @@ METRIC_DEFINITIONS = {
},
# Disadvantaged Performance
"rwm_expected_disadvantaged_pct": {
"name": "RWM Expected % (Disadvantaged)",
"name": "Reading, Writing & Maths Expected % (Disadvantaged)",
"short_name": "Disadvantaged %",
"description": "% of disadvantaged pupils meeting expected",
"type": "percentage",
"category": "equity",
},
"rwm_expected_non_disadvantaged_pct": {
"name": "RWM Expected % (Non-Disadvantaged)",
"name": "Reading, Writing & Maths Expected % (Non-Disadvantaged)",
"short_name": "Non-Disadv %",
"description": "% of non-disadvantaged pupils meeting expected",
"type": "percentage",
@@ -381,7 +381,7 @@ METRIC_DEFINITIONS = {
},
# 3-Year Averages
"rwm_expected_3yr_pct": {
"name": "RWM Expected % (3-Year Avg)",
"name": "Reading, Writing & Maths Expected % (3-Year Avg)",
"short_name": "RWM 3yr %",
"description": "3-year average % meeting expected",
"type": "percentage",
@@ -401,6 +401,70 @@ METRIC_DEFINITIONS = {
"type": "score",
"category": "trends",
},
# ── GCSE Performance (KS4) ────────────────────────────────────────────
"attainment_8_score": {
"name": "Attainment 8",
"short_name": "Att 8",
"description": "Average grade across a pupil's best 8 GCSEs including English and Maths",
"type": "score",
"category": "gcse",
},
"progress_8_score": {
"name": "Progress 8",
"short_name": "P8",
"description": "Progress from KS2 baseline to GCSE relative to similar pupils nationally (0 = national average)",
"type": "score",
"category": "gcse",
},
"english_maths_standard_pass_pct": {
"name": "English & Maths Grade 4+",
"short_name": "E&M 4+",
"description": "% of pupils achieving grade 4 (standard pass) or above in both English and Maths",
"type": "percentage",
"category": "gcse",
},
"english_maths_strong_pass_pct": {
"name": "English & Maths Grade 5+",
"short_name": "E&M 5+",
"description": "% of pupils achieving grade 5 (strong pass) or above in both English and Maths",
"type": "percentage",
"category": "gcse",
},
"ebacc_entry_pct": {
"name": "EBacc Entry %",
"short_name": "EBacc Entry",
"description": "% of pupils entered for the English Baccalaureate (English, Maths, Sciences, Languages, Humanities)",
"type": "percentage",
"category": "gcse",
},
"ebacc_standard_pass_pct": {
"name": "EBacc Grade 4+",
"short_name": "EBacc 4+",
"description": "% of pupils achieving grade 4+ across all EBacc subjects",
"type": "percentage",
"category": "gcse",
},
"ebacc_strong_pass_pct": {
"name": "EBacc Grade 5+",
"short_name": "EBacc 5+",
"description": "% of pupils achieving grade 5+ across all EBacc subjects",
"type": "percentage",
"category": "gcse",
},
"ebacc_avg_score": {
"name": "EBacc Average Score",
"short_name": "EBacc Avg",
"description": "Average points score across EBacc subjects",
"type": "score",
"category": "gcse",
},
"gcse_grade_91_pct": {
"name": "GCSE Grade 91 %",
"short_name": "GCSE 91",
"description": "% of GCSE entries achieving a grade 9 to 1",
"type": "percentage",
"category": "gcse",
},
}
# Ranking columns to include in rankings response
@@ -456,6 +520,16 @@ RANKING_COLUMNS = [
"rwm_expected_3yr_pct",
"reading_avg_3yr",
"maths_avg_3yr",
# GCSE (KS4)
"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",
]
# School listing columns
@@ -469,6 +543,13 @@ SCHOOL_COLUMNS = [
"postcode",
"religious_denomination",
"age_range",
"has_sixth_form",
"status",
"gender",
"admissions_policy",
"ofsted_grade",
"ofsted_date",
"ofsted_framework",
"latitude",
"longitude",
]
+83
View File
@@ -0,0 +1,83 @@
"""Tests for the GIAS code->name dictionaries (spec 2026-07-09).
The dictionaries are generated from the live GIAS bulk CSV by
pipeline/scripts/generate_gias_codes.py — these tests assert the module's
contract, key sentinel values the marts/UI depend on, and that the pipeline
copy has not drifted from the canonical backend module.
"""
import math
from pathlib import Path
from backend.gias_codes import (
ADMISSIONS_POLICY,
ESTABLISHMENT_STATUS,
OFFICIAL_SIXTH_FORM,
PHASE_OF_EDUCATION,
RELIGIOUS_CHARACTER,
SCHOOL_TYPE,
translate,
)
REPO = Path(__file__).resolve().parents[2]
def test_translate_known_code():
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
assert translate(open_code, ESTABLISHMENT_STATUS) == "Open"
def test_translate_unknown_code_degrades_gracefully():
assert translate(9999, ESTABLISHMENT_STATUS) == "Unknown (9999)"
def test_translate_none_and_nan_return_none():
assert translate(None, ESTABLISHMENT_STATUS) is None
assert translate(float("nan"), ESTABLISHMENT_STATUS) is None
def test_translate_accepts_float_codes():
# pd.read_sql yields float columns when NULLs are present
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
assert translate(float(open_code), ESTABLISHMENT_STATUS) == "Open"
def test_sentinel_names_present():
"""Names the marts/UI compare against must exist verbatim."""
assert "Open" in ESTABLISHMENT_STATUS.values()
assert "Open, but proposed to close" in ESTABLISHMENT_STATUS.values()
assert "Has a sixth form" in OFFICIAL_SIXTH_FORM.values()
assert "Primary" in PHASE_OF_EDUCATION.values()
assert "Secondary" in PHASE_OF_EDUCATION.values()
assert "Does not apply" in RELIGIOUS_CHARACTER.values()
assert all(len(d) > 0 for d in (
SCHOOL_TYPE, ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION,
OFFICIAL_SIXTH_FORM, RELIGIOUS_CHARACTER, ADMISSIONS_POLICY,
))
def test_pipeline_copy_is_identical():
canonical = (REPO / "backend" / "gias_codes.py").read_text()
copy = (REPO / "pipeline" / "scripts" / "gias_codes.py").read_text()
assert canonical == copy, (
"pipeline/scripts/gias_codes.py has drifted from backend/gias_codes.py — "
"regenerate with pipeline/scripts/generate_gias_codes.py and copy the file"
)
def test_seed_matches_dictionaries():
import csv
fields = {
"school_type": SCHOOL_TYPE,
"establishment_status": ESTABLISHMENT_STATUS,
"phase_of_education": PHASE_OF_EDUCATION,
"official_sixth_form": OFFICIAL_SIXTH_FORM,
"religious_character": RELIGIOUS_CHARACTER,
"admissions_policy": ADMISSIONS_POLICY,
}
seed_path = REPO / "pipeline" / "transform" / "seeds" / "gias_code_names.csv"
seed: dict[str, dict[int, str]] = {k: {} for k in fields}
with open(seed_path, newline="") as fh:
for row in csv.DictReader(fh):
seed[row["field"]][int(row["code"])] = row["name"]
assert seed == fields
+96
View File
@@ -0,0 +1,96 @@
"""API-boundary translation: marts now carry GIAS codes; the DataFrame the
rest of the backend sees must carry today's name strings."""
import numpy as np
import pandas as pd
from backend.data_loader import translate_gias_code_columns
from backend.gias_codes import ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION
def _code_for(mapping, name):
return next(c for c, n in mapping.items() if n == name)
def test_codes_become_todays_names():
df = pd.DataFrame([{
"urn": 1,
"phase_code": float(_code_for(PHASE_OF_EDUCATION, "Primary")),
"school_type_code": np.nan,
"status_code": float(_code_for(ESTABLISHMENT_STATUS, "Open, but proposed to close")),
"religious_character_code": np.nan,
"admissions_policy_code": np.nan,
}])
out = translate_gias_code_columns(df)
row = out.iloc[0]
assert row["phase"] == "Primary"
assert row["status"] == "Open, but proposed to close"
assert row["school_type"] is None
assert row["religious_denomination"] is None
assert row["admissions_policy"] is None
def test_unknown_code_degrades_not_blanks():
df = pd.DataFrame([{"urn": 1, "phase_code": 9999.0}])
out = translate_gias_code_columns(df)
assert out.iloc[0]["phase"] == "Unknown (9999)"
def test_missing_code_columns_are_a_noop():
"""Old-schema DataFrames (tests, pre-pipeline DBs) pass through untouched."""
df = pd.DataFrame([{"urn": 1, "phase": "Primary", "status": "Open"}])
out = translate_gias_code_columns(df)
assert out.iloc[0]["phase"] == "Primary"
assert out.iloc[0]["status"] == "Open"
def test_load_school_data_survives_premigration_marts(monkeypatch):
"""Real prod state until the nightly pipeline first rebuilds the mart with
the GIAS code columns: marts.dim_school still has the old name columns
(phase, school_type, religious_character, status, admissions_policy)
instead of the new *_code columns. The first query raises UndefinedColumn
on s.phase_code; load_school_data_as_dataframe must retry with the
legacy name-column query rather than swallow the error and return (and
then have load_school_data cache) an empty DataFrame."""
import sqlalchemy.exc
from backend import data_loader
data_loader._df_cache = None
data_loader._df_latest_cache = None
good_df = pd.DataFrame(
[
{
"urn": 1,
"school_name": "Legacy School",
"phase": "Primary",
"school_type": "Academy",
"status": "Open",
}
]
)
calls = []
def fake_read_sql(query, con):
calls.append(query)
if len(calls) == 1:
raise sqlalchemy.exc.ProgrammingError(
"(psycopg2.errors.UndefinedColumn) column s.phase_code does not exist",
None,
None,
)
return good_df.copy()
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
try:
df = data_loader.load_school_data_as_dataframe()
finally:
data_loader._df_cache = None
data_loader._df_latest_cache = None
assert len(calls) == 2, "must retry with the legacy name-column query variant"
assert calls[1] is data_loader._MAIN_QUERY_LEGACY_NAMES
assert not df.empty
assert df["phase"].iloc[0] == "Primary"
assert df["status"].iloc[0] == "Open"
+71
View File
@@ -0,0 +1,71 @@
"""Regression tests for GET /api/schools/{urn}.
Schools with no performance rows (special post-16 institutions, sixth-form
centres, PRUs, brand-new schools) come back from the marts LEFT JOIN with
NaN in every numeric column. The endpoint must still serialize them — a NaN
that reaches Starlette's JSONResponse raises ValueError (allow_nan=False)
and the route 500s, which the frontend then renders as a 404.
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _no_results_school_df() -> pd.DataFrame:
"""One school row as produced by the marts query for a school with no
performance data: GIAS/location fields partly populated, every
results-linked column NaN (including year)."""
return pd.DataFrame(
[
{
"urn": 150275,
"school_name": "West London Performing Arts Academy",
"phase": "Secondary",
"school_type": "Special post 16 institution",
"trust_name": None,
"religious_denomination": "Does not apply",
"gender": None,
"age_range": "16-25",
"admissions_policy": None,
"capacity": np.nan,
"gias_total_pupils": np.nan,
"headteacher_name": None,
"website": None,
"ofsted_grade": np.nan,
"local_authority": "Ealing",
"address": "268 Northfield Avenue, London, W5 4UB",
"postcode": "W5 4UB",
"latitude": 51.4986,
"longitude": -0.3148,
"year": np.nan,
"total_pupils": np.nan,
"eligible_pupils": np.nan,
"rwm_expected_pct": np.nan,
}
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _no_results_school_df)
monkeypatch.setattr(
app_module, "get_supplementary_data", lambda db, urn: {}
)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_school_without_performance_rows_returns_200(client):
resp = client.get("/api/schools/150275")
assert resp.status_code == 200, resp.text
def test_nan_gias_fields_serialize_as_null(client):
info = client.get("/api/schools/150275").json()["school_info"]
assert info["capacity"] is None
assert info["total_pupils"] is None
assert info["school_name"] == "West London Performing Arts Academy"
+70
View File
@@ -0,0 +1,70 @@
"""Tests for GIAS establishment status exposure.
"Open, but proposed to close" schools are now kept by the dims; the API must
surface `status` on list items and school_info so the UI can render the
proposed-to-close marker (listing tag) and notice strip (detail page).
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
PROPOSED = "Open, but proposed to close"
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"age_range": "11-16",
"admissions_policy": None,
"has_sixth_form": False,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 800,
"rwm_expected_pct": np.nan,
"attainment_8_score": 48.0,
}
return pd.DataFrame(
[
{**base, "urn": 200001, "school_name": "Alpha Academy",
"status": "Open"},
{**base, "urn": 200002, "school_name": "Sarson High School",
"status": PROPOSED},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def test_list_payload_includes_status(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[200001]["status"] == "Open"
assert by_urn[200002]["status"] == PROPOSED
def test_detail_payload_includes_status(client):
resp = client.get("/api/schools/200002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["status"] == PROPOSED
+173
View File
@@ -0,0 +1,173 @@
"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).
The filter and payloads must use dim_school.has_sixth_form, not the old
age_range-contains-"18" substring heuristic. The key regression case is a
16-19 sixth-form college: flag true, but "16-19" contains no "18".
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
"""Latest-year snapshot rows as produced by load_latest_school_data."""
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"admissions_policy": None,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 1000,
"rwm_expected_pct": np.nan,
"attainment_8_score": 50.0,
}
return pd.DataFrame(
[
# 11-18 school WITH a registered sixth form
{**base, "urn": 100001, "school_name": "Alpha High",
"age_range": "11-18", "has_sixth_form": True},
# 16-19 college: old heuristic said NO ("16-19" has no "18"),
# GIAS flag says YES — must appear in the yes-filter results
{**base, "urn": 100002, "school_name": "Beta Sixth Form College",
"age_range": "16-19", "has_sixth_form": True},
# 11-18 age range on paper but NO registered sixth form:
# old heuristic said YES, GIAS flag says NO
{**base, "urn": 100003, "school_name": "Gamma Academy",
"age_range": "11-18", "has_sixth_form": False},
# Missing flag (pipeline not yet re-run) — must not crash,
# must not match the yes-filter
{**base, "urn": 100004, "school_name": "Delta School",
"age_range": "11-16", "has_sixth_form": None},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def _urns(resp):
return sorted(s["urn"] for s in resp.json()["schools"])
def test_filter_yes_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=yes")
assert resp.status_code == 200, resp.text
# 16-19 college included; 11-18-without-sixth-form excluded
assert _urns(resp) == [100001, 100002]
def test_filter_no_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=no")
assert resp.status_code == 200, resp.text
# Gamma (flag false) and Delta (flag missing => not true)
assert _urns(resp) == [100003, 100004]
def test_list_payload_includes_flag(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[100002]["has_sixth_form"] is True
assert by_urn[100003]["has_sixth_form"] is False
assert by_urn[100004]["has_sixth_form"] is None
def test_detail_payload_includes_flag(client):
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
def test_detail_payload_serializes_numpy_bool(monkeypatch):
"""Once the pipeline has run, has_sixth_form is a real bool dtype column
(dbt not_null test guarantees no NULLs), so row access yields
numpy.bool_ rather than a Python bool. convert_to_native must handle it —
otherwise FastAPI's jsonable_encoder raises ValueError and the detail
endpoint 500s (C2)."""
from backend import app as app_module
df = _schools_df()
# Drop the row with a None flag — this fixture models the post-pipeline
# state where the column is a genuine, fully-populated bool dtype.
df = df[df["has_sixth_form"].notna()].reset_index(drop=True)
df["has_sixth_form"] = df["has_sixth_form"].astype(bool)
assert df["has_sixth_form"].dtype == bool
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
client = TestClient(app_module.app, raise_server_exceptions=False)
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
def test_load_school_data_survives_missing_has_sixth_form_column(monkeypatch):
"""Real prod state until the nightly pipeline first rebuilds the mart:
marts.dim_school lacks has_sixth_form entirely. The first query raises
UndefinedColumn; load_school_data_as_dataframe must retry without the
column (synthesizing it as None) rather than swallow the error and
return (and then have load_school_data cache) an empty DataFrame (C1)."""
import sqlalchemy.exc
from backend import data_loader
data_loader._df_cache = None
data_loader._df_latest_cache = None
good_df = pd.DataFrame(
[
{
"urn": 1,
"school_name": "Fallback School",
"school_type": "Academy",
"has_sixth_form": None,
}
]
)
calls = []
def fake_read_sql(query, con):
calls.append(query)
if len(calls) == 1:
raise sqlalchemy.exc.ProgrammingError(
"SELECT ...",
None,
Exception(
"(psycopg2.errors.UndefinedColumn) column s.has_sixth_form "
"does not exist"
),
)
return good_df.copy()
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
try:
df = data_loader.load_school_data_as_dataframe()
finally:
data_loader._df_cache = None
data_loader._df_latest_cache = None
assert len(calls) == 2, "must retry with the no-sixth-form query variant"
assert calls[1] is data_loader._MAIN_QUERY_NO_SIXTH_FORM
assert not df.empty
assert "has_sixth_form" in df.columns
assert df["has_sixth_form"].iloc[0] is None
+2
View File
@@ -11,6 +11,8 @@ def convert_to_native(value: Any) -> Any:
"""Convert numpy types to native Python types for JSON serialization."""
if pd.isna(value):
return None
if isinstance(value, np.bool_):
return bool(value)
if isinstance(value, (np.integer,)):
return int(value)
if isinstance(value, (np.floating,)):
+2 -1
View File
@@ -13,7 +13,7 @@ WHEN TO BUMP:
"""
# Current schema version - increment when models change
SCHEMA_VERSION = 5
SCHEMA_VERSION = 6
# Changelog for documentation
SCHEMA_CHANGELOG = {
@@ -22,4 +22,5 @@ SCHEMA_CHANGELOG = {
3: "Added supplementary data tables: ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance; GIAS columns on schools",
4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)",
5: "Apply ALTER TABLE additions for RC columns missed by create_all on existing tables",
6: "Removed the Ofsted Parent View feature: dropped fact_parent_view table and model",
}
+16
View File
@@ -105,10 +105,26 @@ This starts:
- `GET /api/metrics` - Metric definitions (single source of truth)
- `GET /api/data-info` - Database stats
## SDLC
Full details in `docs/DEPLOY.md`. The short version:
- **Never push to `main` directly.** Work on a feature branch and open a PR;
branch protection requires the PR checks (typecheck, tests, builds, AI review)
to pass before merge.
- Merging to `main` deploys automatically: images are built once, deployed to
the **staging** Portainer stack, verified by the Playwright journeys in
`e2e/`, and only then retagged `:prod` and rolled out to production.
- If you change user-facing behaviour, update or extend the `e2e/` journey
tests in the same PR — they are the promotion gate.
## Recent Changes
- Added staging environment + automated staging→prod pipeline (Gitea Actions)
- Migrated from CSV file storage to PostgreSQL database
- Added location-based search using postcode geocoding
- Added local authority filter to rankings
- Improved frontend with featured schools, loading states, API caching
# Important
- Do not attempt to start a local server to test the application, it does not work
BIN
View File
Binary file not shown.
-3
View File
@@ -1,3 +0,0 @@
# Place your CSV data files here
# Download from: https://www.compare-school-performance.service.gov.uk/download-data
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
Field Number,Field Reference,Field Name,Values,Data Format,LA level field?,National level field?
1,URN,School Unique Reference Number,999999,I6,No,No
2,LA,LA number,999,I3,Yes,No
3,ESTAB,ESTAB number,9999,I4,No,No
4,SCHOOLTYPE,Type of school,String,,No,No
5,NOR,Total number of pupils on roll,9999 or NA,,Yes,Yes
6,NORG,Number of girls on roll,9999 or NA,,Yes,Yes
7,NORB,Number of boys on roll,9999 or NA,,Yes,Yes
8,PNORG,Percentage of girls on roll,99.9 or NA,,Yes,Yes
9,PNORB,Percentage of boys on roll,99.9 or NA,,Yes,Yes
10,TSENELSE,Number of eligible pupils with an EHC plan,9999 or NA,A4,Yes,Yes
11,PSENELSE,Percentage of eligible pupils with an EHC plan,99.9 or NA,A4,Yes,Yes
12,TSENELK,Number of eligible pupils with SEN support,9999 or NA,A4,Yes,Yes
13,PSENELK,Percentage of eligible pupils with SEN support,99.9 or NA,A4,Yes,Yes
14,NUMEAL,No. pupils where English not first language,9999 or NA,A4,Yes,Yes
15,NUMENGFL,No. pupils with English first language,9999 or NA,A4,Yes,Yes
16,NUMUNCFL,No. pupils where first language is unclassified,9999 or NA,A4,Yes,Yes
17,PNUMEAL,% pupils where English not first language,99.9 or NA,A4,Yes,Yes
18,PNUMENGFL,% pupils with English first language,99.9 or NA,A4,Yes,Yes
19,PNUMUNCFL,% pupils where first language is unclassified,99.9 or NA,A4,Yes,Yes
20,NUMFSM,No. pupils eligible for free school meals,9999 or NA,A4,Yes,Yes
21,NUMFSMEVER,Number of pupils eligible for FSM at any time during the past 6 years,9999 or NA,A6,Yes,Yes
22,NORFSMEVER,Total pupils for FSMEver,9999 or NA,,Yes,Yes
23,PNUMFSMEVER,Percentage of pupils eligible for FSM at any time during the past 6 years,99.9 or NA,A4,Yes,Yes
1 Field Number Field Reference Field Name Values Data Format LA level field? National level field?
2 1 URN School Unique Reference Number 999999 I6 No No
3 2 LA LA number 999 I3 Yes No
4 3 ESTAB ESTAB number 9999 I4 No No
5 4 SCHOOLTYPE Type of school String No No
6 5 NOR Total number of pupils on roll 9999 or NA Yes Yes
7 6 NORG Number of girls on roll 9999 or NA Yes Yes
8 7 NORB Number of boys on roll 9999 or NA Yes Yes
9 8 PNORG Percentage of girls on roll 99.9 or NA Yes Yes
10 9 PNORB Percentage of boys on roll 99.9 or NA Yes Yes
11 10 TSENELSE Number of eligible pupils with an EHC plan 9999 or NA A4 Yes Yes
12 11 PSENELSE Percentage of eligible pupils with an EHC plan 99.9 or NA A4 Yes Yes
13 12 TSENELK Number of eligible pupils with SEN support 9999 or NA A4 Yes Yes
14 13 PSENELK Percentage of eligible pupils with SEN support 99.9 or NA A4 Yes Yes
15 14 NUMEAL No. pupils where English not first language 9999 or NA A4 Yes Yes
16 15 NUMENGFL No. pupils with English first language 9999 or NA A4 Yes Yes
17 16 NUMUNCFL No. pupils where first language is unclassified 9999 or NA A4 Yes Yes
18 17 PNUMEAL % pupils where English not first language 99.9 or NA A4 Yes Yes
19 18 PNUMENGFL % pupils with English first language 99.9 or NA A4 Yes Yes
20 19 PNUMUNCFL % pupils where first language is unclassified 99.9 or NA A4 Yes Yes
21 20 NUMFSM No. pupils eligible for free school meals 9999 or NA A4 Yes Yes
22 21 NUMFSMEVER Number of pupils eligible for FSM at any time during the past 6 years 9999 or NA A6 Yes Yes
23 22 NORFSMEVER Total pupils for FSMEver 9999 or NA Yes Yes
24 23 PNUMFSMEVER Percentage of pupils eligible for FSM at any time during the past 6 years 99.9 or NA A4 Yes Yes
-312
View File
@@ -1,312 +0,0 @@
Column,Field Name,Label/Description
1,RECTYPE,Record type
2,AlphaIND,Alphabetic index
3,LEA,Local authority number
4,ESTAB,Establishment number
5,URN,School unique reference number
6,SCHNAME,School/Local authority name
7,ADDRESS1,School address (1)
8,ADDRESS2,School address (2)
9,ADDRESS3,School address (3)
10,TOWN,School town
11,PCODE,School postcode
12,TELNUM,School telephone number
13,PCON_CODE,School parliamentary constituency code
14,PCON_NAME,School parliamentary constituency name
15,URN_AC,Converter academy: URN
16,SCHNAME_AC,Converter academy: name
17,OPEN_AC,Converter academy: open date
18,NFTYPE,School type
19,ICLOSE,Closed Flag
20,RELDENOM,Religious denomination
21,AGERANGE,Age range
22,TAB15,School published in secondary school (key stage 4) performance tables
23,TAB1618,School published in school and college (key stage 5) performance tables
24,TOTPUPS,Total number of pupils (including part-time pupils)
25,TPUPYEAR,Number of pupils aged 11
26,TELIG,Published eligible pupil number
27,BELIG,Eligible boys on school roll at time of tests
28,GELIG,Eligible girls on school roll at time of tests
29,PBELIG,Percentage of eligible boys on school roll at time of tests
30,PGELIG,Percentage of eligible girls on school roll at time of tests
31,TKS1AVERAGE,Cohort level key stage 1 average points score [not populated in 2025]
32,TKS1GROUP_L,Number of pupils in cohort with low KS1 attainment [not populated in 2025]
33,PTKS1GROUP_L,Percentage of pupils in cohort with low KS1 attainment [not populated in 2025]
34,TKS1GROUP_M,Number of pupils in cohort with medium KS1 attainment [not populated in 2025]
35,PTKS1GROUP_M,Percentage of pupils in cohort with medium KS1 attainment [not populated in 2025]
36,TKS1GROUP_H,Number of pupils in cohort high KS1 attainment [not populated in 2025]
37,PTKS1GROUP_H,Percentage of pupils in cohort with high KS1 attainment [not populated in 2025]
38,TKS1GROUP_NA,No. of pupils in KS1 group not calculable [not populated in 2025]
39,PTKS1GROUP_NA,Percentage of pupils in KS1group not calculable [not populated in 2025]
40,TFSM6CLA1A,Number of key stage 2 disadvantaged pupils (those who were eligible for free school meals in last 6 years or are looked after by the LA for a day or more or who have been adopted from care)
41,PTFSM6CLA1A,Percentage of key stage 2 disadvantaged pupils
42,TNotFSM6CLA1A,Number of key stage 2 pupils who are not disadvantaged
43,PTNotFSM6CLA1A,Percentage of key stage 2 pupils who are not disadvantaged
44,TEALGRP2,Number of eligible pupils with English as additional language (EAL)
45,PTEALGRP2,Percentage of eligible pupils with English as additional language (EAL)
46,TMOBN,Number of eligible pupils classified as non-mobile
47,PTMOBN,Percentage of eligible pupils classified as non-mobile
48,PTRWM_EXP,"Percentage of pupils reaching the expected standard in reading, writing and maths"
49,PTRWM_HIGH,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing
50,READPROG,Reading progress measure [not populated in 2025]
51,READPROG_LOWER,Reading progress measure - lower confidence limit [not populated in 2025]
52,READPROG_UPPER,Reading progress measure - upper confidence limit [not populated in 2025]
53,READCOV,Reading progress measure - coverage [not populated in 2025]
54,WRITPROG,Writing progress measure [not populated in 2025]
55,WRITPROG_LOWER,Writing progress measure - lower confidence limit [not populated in 2025]
56,WRITPROG_UPPER,Writing progress measure - upper confidence limit [not populated in 2025]
57,WRITCOV,Writing progress measure - coverage [not populated in 2025]
58,MATPROG,Maths progress measure [not populated in 2025]
59,MATPROG_LOWER,Maths progress measure - lower confidence limit [not populated in 2025]
60,MATPROG_UPPER,Maths progress measure - upper confidence limit [not populated in 2025]
61,MATCOV,Maths progress measure - coverage [not populated in 2025]
62,PTREAD_EXP,Percentage of pupils reaching the expected standard in reading
63,PTREAD_HIGH,Percentage of pupils achieving a high score in reading
64,PTREAD_AT,Percentage of pupils absent from or not able to access the test in reading
65,READ_AVERAGE,Average scaled score in reading
66,PTGPS_EXP,"Percentage of pupils reaching the expected standard in grammar, punctuation and spelling"
67,PTGPS_HIGH,"Percentage of pupils achieving a high score in grammar, punctuation and spelling"
68,PTGPS_AT,"Percentage of pupils absent from or not able to access the test in grammar, punctuation and spelling"
69,GPS_AVERAGE,"Average scaled score in grammar, punctuation and spelling"
70,PTMAT_EXP,Percentage of pupils reaching the expected standard in maths
71,PTMAT_HIGH,Percentage of pupils achieving a high score in maths
72,PTMAT_AT,Percentage of pupils absent from or not able to access the test in maths
73,MAT_AVERAGE,Average scaled score in maths
74,PTWRITTA_EXP,Percentage of pupils reaching the expected standard in writing
75,PTWRITTA_HIGH,Percentage of pupils working at greater depth within the expected standard in writing
76,PTWRITTA_WTS,Percentage of pupils working towards the expected standard in writing
77,PTWRITTA_AD,Percentage of pupils absent or disapplied in writing TA
78,PTSCITA_EXP,Percentage of pupils reaching the expected standard in science TA
79,PTSCITA_AD,Percentage of pupils absent or disapplied in science TA
80,PTRWM_EXP_B,"Percentage of boys reaching the expected standard in reading, writing and maths"
81,PTRWM_EXP_G,"Percentage of girls reaching the expected standard in reading, writing and maths"
82,PTRWM_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
83,PTRWM_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
84,PTRWM_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
85,PTRWM_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths"
86,PTRWM_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths"
87,DIFFN_RWM_EXP,"Difference between school percentage of disavantaged pupils and national percentage of other pupils reaching the expected standard in reading, writing and maths "
88,PTRWM_EXP_EAL,"Percentage of EAL pupils reaching the expected standard in reading, writing and maths"
89,PTRWM_EXP_MOBN,"Percentage of non-mobile pupils reaching the expected standard in reading, writing and maths"
90,PTRWM_HIGH_B,Percentage of boys achieving a high score in reading and maths and working at greater depth in writing
91,PTRWM_HIGH_G,"Percentage of girls reaching the HIGHected standard in reading, writing and maths"
92,PTRWM_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
93,PTRWM_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
94,PTRWM_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
95,PTRWM_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
96,PTRWM_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
97,DIFFN_RWM_HIGH,"Difference between school percentage of disavantaged pupils and national percentage of other pupils achieving a high score in reading, writing and maths "
98,PTRWM_HIGH_EAL,Percentage of EAL pupils achieving a high score in reading and maths and working at greater depth in writing
99,PTRWM_HIGH_MOBN,Percentage of non-mobile pupils achieving a high score in reading and maths and working at greater depth in writing
100,READPROG_B,Reading progress measure for boys [not populated in 2025]
101,READPROG_B_LOWER,Reading progress measure for boys - lower confidence limit [not populated in 2025]
102,READPROG_B_UPPER,Reading progress measure for boys - upper confidence limit [not populated in 2025]
103,READPROG_G,Reading progress measure for girls [not populated in 2025]
104,READPROG_G_LOWER,Reading progress measure for girls - lower confidence limit [not populated in 2025]
105,READPROG_G_UPPER,Reading progress measure for girls - upper confidence limit [not populated in 2025]
106,READPROG_L,Reading progress measure for pupils with low prior attainment [not populated in 2025]
107,READPROG_L_LOWER,Reading progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
108,READPROG_L_UPPER,Reading progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
109,READPROG_M,Reading progress measure for pupils with medium prior attainment [not populated in 2025]
110,READPROG_M_LOWER,Reading progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
111,READPROG_M_UPPER,Reading progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
112,READPROG_H,Reading progress measure for pupils with high prior attainment [not populated in 2025]
113,READPROG_H_LOWER,Reading progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
114,READPROG_H_UPPER,Reading progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
115,READPROG_FSM6CLA1A,Reading progress measure for disadvantaged pupils [not populated in 2025]
116,READPROG_FSM6CLA1A_LOWER,Reading progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
117,READPROG_FSM6CLA1A_UPPER,Reading progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
118,READPROG_NotFSM6CLA1A,Reading progress measure for non-disadvantaged pupils [not populated in 2025]
119,READPROG_NotFSM6CLA1A_LOWER,Reading progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
120,READPROG_NotFSM6CLA1A_UPPER,Reading progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
121,DIFFN_READPROG,Difference between reading progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
122,READPROG_EAL,Reading progress measure for EAL pupils [not populated in 2025]
123,READPROG_EAL_LOWER,Reading progress measure for EAL pupils - lower confidence limit [not populated in 2025]
124,READPROG_EAL_UPPER,Reading progress measure for EAL pupils - upper confidence limit [not populated in 2025]
125,READPROG_MOBN,Reading progress measure for non-mobile pupils [not populated in 2025]
126,READPROG_MOBN_LOWER,Reading progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
127,READPROG_MOBN_UPPER,Reading progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
128,WRITPROG_B,Writing progress measure for boys [not populated in 2025]
129,WRITPROG_B_LOWER,Writing progress measure for boys - lower confidence limit [not populated in 2025]
130,WRITPROG_B_UPPER,Writing progress measure for boys - upper confidence limit [not populated in 2025]
131,WRITPROG_G,Writing progress measure for girls [not populated in 2025]
132,WRITPROG_G_LOWER,Writing progress measure for girls - lower confidence limit [not populated in 2025]
133,WRITPROG_G_UPPER,Writing progress measure for girls - upper confidence limit [not populated in 2025]
134,WRITPROG_L,Writing progress measure for pupils with low prior attainment [not populated in 2025]
135,WRITPROG_L_LOWER,Writing progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
136,WRITPROG_L_UPPER,Writing progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
137,WRITPROG_M,Writing progress measure for pupils with medium prior attainment [not populated in 2025]
138,WRITPROG_M_LOWER,Writing progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
139,WRITPROG_M_UPPER,Writing progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
140,WRITPROG_H,Writing progress measure for pupils with high prior attainment [not populated in 2025]
141,WRITPROG_H_LOWER,Writing progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
142,WRITPROG_H_UPPER,Writing progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
143,WRITPROG_FSM6CLA1A,Writing progress measure for disadvantaged pupils [not populated in 2025]
144,WRITPROG_FSM6CLA1A_LOWER,Writing progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
145,WRITPROG_FSM6CLA1A_UPPER,Writing progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
146,WRITPROG_NotFSM6CLA1A,Writing progress measure for non-disadvantaged pupils [not populated in 2025]
147,WRITPROG_NotFSM6CLA1A_LOWER,Writing progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
148,WRITPROG_NotFSM6CLA1A_UPPER,Writing progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
149,DIFFN_WRITPROG,Difference between writing progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
150,WRITPROG_EAL,Writing progress measure for EAL pupils [not populated in 2025]
151,WRITPROG_EAL_LOWER,Writing progress measure for EAL pupils - lower confidence limit [not populated in 2025]
152,WRITPROG_EAL_UPPER,Writing progress measure for EAL pupils - upper confidence limit [not populated in 2025]
153,WRITPROG_MOBN,Writing progress measure for non-mobile pupils [not populated in 2025]
154,WRITPROG_MOBN_LOWER,Writing progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
155,WRITPROG_MOBN_UPPER,Writing progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
156,MATPROG_B,Maths progress measure for boys [not populated in 2025]
157,MATPROG_B_LOWER,Maths progress measure for boys - lower confidence limit [not populated in 2025]
158,MATPROG_B_UPPER,Maths progress measure for boys - upper confidence limit [not populated in 2025]
159,MATPROG_G,Maths progress measure for girls [not populated in 2025]
160,MATPROG_G_LOWER,Maths progress measure for girls - lower confidence limit [not populated in 2025]
161,MATPROG_G_UPPER,Maths progress measure for girls - upper confidence limit [not populated in 2025]
162,MATPROG_L,Maths progress measure for pupils with low prior attainment [not populated in 2025]
163,MATPROG_L_LOWER,Maths progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
164,MATPROG_L_UPPER,Maths progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
165,MATPROG_M,Maths progress measure for pupils with medium prior attainment [not populated in 2025]
166,MATPROG_M_LOWER,Maths progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
167,MATPROG_M_UPPER,Maths progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
168,MATPROG_H,Maths progress measure for pupils with high prior attainment [not populated in 2025]
169,MATPROG_H_LOWER,Maths progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
170,MATPROG_H_UPPER,Maths progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
171,MATPROG_FSM6CLA1A,Maths progress measure for disadvantaged pupils [not populated in 2025]
172,MATPROG_FSM6CLA1A_LOWER,Maths progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
173,MATPROG_FSM6CLA1A_UPPER,Maths progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
174,MATPROG_NotFSM6CLA1A,Maths progress measure for non-disadvantaged pupils [not populated in 2025]
175,MATPROG_NotFSM6CLA1A_LOWER,Maths progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
176,MATPROG_NotFSM6CLA1A_UPPER,Maths progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
177,DIFFN_MATPROG,Difference between maths progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
178,MATPROG_EAL,Maths progress measure for EAL pupils [not populated in 2025]
179,MATPROG_EAL_LOWER,Maths progress measure for EAL pupils - lower confidence limit [not populated in 2025]
180,MATPROG_EAL_UPPER,Maths progress measure for EAL pupils - upper confidence limit [not populated in 2025]
181,MATPROG_MOBN,Maths progress measure for non-mobile pupils [not populated in 2025]
182,MATPROG_MOBN_LOWER,Maths progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
183,MATPROG_MOBN_UPPER,Maths progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
184,READ_AVERAGE_B,Average scaled score in reading for boys
185,READ_AVERAGE_G,Average scaled score in reading for girls
186,READ_AVERAGE_L,Average scaled score in reading for pupils with low prior attainment [not populated in 2025]
187,READ_AVERAGE_M,Average scaled score in reading for pupils with medium prior attainment [not populated in 2025]
188,READ_AVERAGE_H,Average scaled score in reading for pupils with high prior attainment [not populated in 2025]
189,READ_AVERAGE_FSM6CLA1A,Average scaled score in reading for disadvantaged pupils
190,READ_AVERAGE_NotFSM6CLA1A,Average scaled score in reading for non-disadvantaged pupils
191,READ_AVERAGE_EAL,Average scaled score in reading for EAL pupils
192,READ_AVERAGE_MOBN,Average scaled score in reading for MOBN pupils
193,MAT_AVERAGE_B,Average scaled score in maths for boys
194,MAT_AVERAGE_G,Average scaled score in maths for girls
195,MAT_AVERAGE_L,Average scaled score in maths for pupils with low prior attainment [not populated in 2025]
196,MAT_AVERAGE_M,Average scaled score in maths for pupils with medium prior attainment [not populated in 2025]
197,MAT_AVERAGE_H,Average scaled score in maths for pupils with high prior attainment [not populated in 2025]
198,MAT_AVERAGE_FSM6CLA1A,Average scaled score in maths for disadvantaged pupils
199,MAT_AVERAGE_NotFSM6CLA1A,Average scaled score in maths for non-disadvantaged pupils
200,MAT_AVERAGE_EAL,Average scaled score in maths for EAL pupils
201,MAT_AVERAGE_MOBN,Average scaled score in maths for MOBN pupils
202,GPS_AVERAGE_B,Average scaled score in GPS for boys
203,GPS_AVERAGE_G,Average scaled score in GPS for girls
204,GPS_AVERAGE_L,Average scaled score in GPS for pupils with low prior attainment [not populated in 2025]
205,GPS_AVERAGE_M,Average scaled score in GPS for pupils with medium prior attainment [not populated in 2025]
206,GPS_AVERAGE_H,Average scaled score in GPS for pupils with high prior attainment [not populated in 2025]
207,GPS_AVERAGE_FSM6CLA1A,Average scaled score in GPS for disadvantaged pupils
208,GPS_AVERAGE_NotFSM6CLA1A,Average scaled score in GPS for non-disadvantaged pupils
209,GPS_AVERAGE_EAL,Average scaled score in GPS for EAL pupils
210,GPS_AVERAGE_MOBN,Average scaled score in GPS for MOBN pupils
211,PTREAD_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in reading [not populated in 2025]
212,PTREAD_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in reading [not populated in 2025]
213,PTREAD_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in reading [not populated in 2025]
214,PTREAD_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in reading
215,PTREAD_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in reading
216,PTGPS_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
217,PTGPS_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
218,PTGPS_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
219,PTGPS_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
220,PTGPS_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
221,PTMAT_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in maths [not populated in 2025]
222,PTMAT_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in maths [not populated in 2025]
223,PTMAT_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in maths [not populated in 2025]
224,PTMAT_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in maths
225,PTMAT_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in maths
226,PTWRITTA_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in writing [not populated in 2025]
227,PTWRITTA_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in writing [not populated in 2025]
228,PTWRITTA_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in writing [not populated in 2025]
229,PTWRITTA_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in writing
230,PTWRITTA_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in writing
231,PTREAD_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading [not populated in 2025]
232,PTREAD_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading [not populated in 2025]
233,PTREAD_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading [not populated in 2025]
234,PTREAD_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading
235,PTREAD_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading
236,PTGPS_HIGH_L,"Percentage of pupils with low prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
237,PTGPS_HIGH_M,"Percentage of pupils with medium prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
238,PTGPS_HIGH_H,"Percentage of pupils with high prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
239,PTGPS_HIGH_FSM6CLA1A,"Percentage of disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
240,PTGPS_HIGH_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
241,PTMAT_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in maths [not populated in 2025]
242,PTMAT_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in maths [not populated in 2025]
243,PTMAT_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in maths [not populated in 2025]
244,PTMAT_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in maths
245,PTMAT_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in maths
246,PTWRITTA_HIGH_L,Percentage of pupils with low prior attainment working at greater depth in writing [not populated in 2025]
247,PTWRITTA_HIGH_M,Percentage of pupils with medium prior attainment working at greater depth in writing [not populated in 2025]
248,PTWRITTA_HIGH_H,Percentage of pupils with high prior attainment working at greater depth in writing [not populated in 2025]
249,PTWRITTA_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils working at greater depth in writing
250,PTWRITTA_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils working at greater depth in writing
251,TEALGRP1,Number of eligible pupils with English as first language
252,PTEALGRP1,Percentage of eligible pupils with English as first language
253,TEALGRP3,Number of eligible pupils with unclassified language
254,PTEALGRP3,Percentage of eligible pupils with unclassified language
255,TSENELE,Number of eligible pupils with EHC plan
256,PSENELE,Percentage of eligible pupils with EHC plan
257,TSENELK,Number of eligible pupils with SEN support
258,PSENELK,Percentage of eligible pupils with SEN support
259,TSENELEK,Number of eligible pupils with SEN (EHC plan or SEN support)
260,PSENELEK,Percentage of eligible pupils with SEN (EHC plan or SEN support)
261,TELIG_24,Number of eligible pupils 2024
262,PTFSM6CLA1A_24,Percentage of key stage 2 disadvantaged pupils one year prior
263,PTNOTFSM6CLA1A_24,Percentage of key stage 2 pupils who are not disadvantaged one year prior
264,PTRWM_EXP_24,"Percentage of pupils reaching the expected standard in reading, writing and maths one year prior"
265,PTRWM_HIGH_24,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
266,PTRWM_EXP_FSM6CLA1A_24,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
267,PTRWM_HIGH_FSM6CLA1A_24,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
268,PTRWM_EXP_NotFSM6CLA1A_24,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
269,PTRWM_HIGH_NotFSM6CLA1A_24,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
270,READPROG_24,Reading progress measure - one year prior [not populated in 2025]
271,READPROG_LOWER_24,Reading progress measure - lower confidence limit - one year prior [not populated in 2025]
272,READPROG_UPPER_24,Reading progress measure - upper confidence limit - one year prior [not populated in 2025]
273,WRITPROG_24,Writing progress measure - one year prior [not populated in 2025]
274,WRITPROG_LOWER_24,Writing progress measure - lower confidence limit - one year prior [not populated in 2025]
275,WRITPROG_UPPER_24,Writing progress measure - upper confidence limit - one year prior [not populated in 2025]
276,MATPROG_24,Maths progress measure - one year prior [not populated in 2025]
277,MATPROG_LOWER_24,Maths progress measure - lower confidence limit - one year prior [not populated in 2025]
278,MATPROG_UPPER_24,Maths progress measure - upper confidence limit - one year prior [not populated in 2025]
279,READ_AVERAGE_24,Average scaled score in reading - one year prior
280,MAT_AVERAGE_24,Average scaled score in maths - one year prior
281,TELIG_23,Number of eligible pupils 2023
282,PTFSM6CLA1A_23,Percentage of key stage 2 disadvantaged pupils - two years prior
283,PTNOTFSM6CLA1A_23,Percentage of key stage 2 pupils who are not disadvantaged - two years prior
284,PTRWM_EXP_23,"Percentage of pupils reaching the expected standard in reading, writing and maths - two years prior"
285,PTRWM_HIGH_23,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
286,PTRWM_EXP_FSM6CLA1A_23,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
287,PTRWM_HIGH_FSM6CLA1A_23,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
288,PTRWM_EXP_NotFSM6CLA1A_23,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
289,PTRWM_HIGH_NotFSM6CLA1A_23,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
290,READPROG_23,Reading progress measure - two years prior
291,READPROG_LOWER_23,Reading progress measure - lower confidence limit - two years prior
292,READPROG_UPPER_23,Reading progress measure - upper confidence limit - two years prior
293,WRITPROG_23,Writing progress measure - two years prior
294,WRITPROG_LOWER_23,Writing progress measure - lower confidence limit - two years prior
295,WRITPROG_UPPER_23,Writing progress measure - upper confidence limit - two years prior
296,MATPROG_23,Maths progress measure - two years prior
297,MATPROG_LOWER_23,Maths progress measure - lower confidence limit - two years prior
298,MATPROG_UPPER_23,Maths progress measure - upper confidence limit - two years prior
299,READ_AVERAGE_23,Average scaled score in reading - two years prior
300,MAT_AVERAGE_23,Average scaled score in maths - two years prior
301,TELIG_3YR,Total number of pupils at the end of Key Stage 2 over the past three years
302,PTRWM_EXP_3YR,"Percentage of pupils reaching the expected standard in reading, writing and maths - 3 year total"
303,PTRWM_HIGH_3YR,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - 3 year total
304,READ_AVERAGE_3YR,Average scaled score in reading - 3 year average
305,MAT_AVERAGE_3YR,Average scaled score in maths - 3 year average
306,READPROG_UNADJUSTED,Unadjusted reading progress measure [not populated in 2025]
307,WRITPROG_UNADJUSTED,Unadjusted writing progress measure [not populated in 2025]
308,MATPROG_UNADJUSTED,Unadjusted maths progress measure [not populated in 2025]
309,READPROG_DESCR,Reading progress measure 'description' [not populated in 2025]
310,WRITPROG_DESCR,Writing progress measure 'description' [not populated in 2025]
311,MATPROG_DESCR,Maths progress measure 'description' [not populated in 2025]
1 Column Field Name Label/Description
2 1 RECTYPE Record type
3 2 AlphaIND Alphabetic index
4 3 LEA Local authority number
5 4 ESTAB Establishment number
6 5 URN School unique reference number
7 6 SCHNAME School/Local authority name
8 7 ADDRESS1 School address (1)
9 8 ADDRESS2 School address (2)
10 9 ADDRESS3 School address (3)
11 10 TOWN School town
12 11 PCODE School postcode
13 12 TELNUM School telephone number
14 13 PCON_CODE School parliamentary constituency code
15 14 PCON_NAME School parliamentary constituency name
16 15 URN_AC Converter academy: URN
17 16 SCHNAME_AC Converter academy: name
18 17 OPEN_AC Converter academy: open date
19 18 NFTYPE School type
20 19 ICLOSE Closed Flag
21 20 RELDENOM Religious denomination
22 21 AGERANGE Age range
23 22 TAB15 School published in secondary school (key stage 4) performance tables
24 23 TAB1618 School published in school and college (key stage 5) performance tables
25 24 TOTPUPS Total number of pupils (including part-time pupils)
26 25 TPUPYEAR Number of pupils aged 11
27 26 TELIG Published eligible pupil number
28 27 BELIG Eligible boys on school roll at time of tests
29 28 GELIG Eligible girls on school roll at time of tests
30 29 PBELIG Percentage of eligible boys on school roll at time of tests
31 30 PGELIG Percentage of eligible girls on school roll at time of tests
32 31 TKS1AVERAGE Cohort level key stage 1 average points score [not populated in 2025]
33 32 TKS1GROUP_L Number of pupils in cohort with low KS1 attainment [not populated in 2025]
34 33 PTKS1GROUP_L Percentage of pupils in cohort with low KS1 attainment [not populated in 2025]
35 34 TKS1GROUP_M Number of pupils in cohort with medium KS1 attainment [not populated in 2025]
36 35 PTKS1GROUP_M Percentage of pupils in cohort with medium KS1 attainment [not populated in 2025]
37 36 TKS1GROUP_H Number of pupils in cohort high KS1 attainment [not populated in 2025]
38 37 PTKS1GROUP_H Percentage of pupils in cohort with high KS1 attainment [not populated in 2025]
39 38 TKS1GROUP_NA No. of pupils in KS1 group not calculable [not populated in 2025]
40 39 PTKS1GROUP_NA Percentage of pupils in KS1group not calculable [not populated in 2025]
41 40 TFSM6CLA1A Number of key stage 2 disadvantaged pupils (those who were eligible for free school meals in last 6 years or are looked after by the LA for a day or more or who have been adopted from care)
42 41 PTFSM6CLA1A Percentage of key stage 2 disadvantaged pupils
43 42 TNotFSM6CLA1A Number of key stage 2 pupils who are not disadvantaged
44 43 PTNotFSM6CLA1A Percentage of key stage 2 pupils who are not disadvantaged
45 44 TEALGRP2 Number of eligible pupils with English as additional language (EAL)
46 45 PTEALGRP2 Percentage of eligible pupils with English as additional language (EAL)
47 46 TMOBN Number of eligible pupils classified as non-mobile
48 47 PTMOBN Percentage of eligible pupils classified as non-mobile
49 48 PTRWM_EXP Percentage of pupils reaching the expected standard in reading, writing and maths
50 49 PTRWM_HIGH Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing
51 50 READPROG Reading progress measure [not populated in 2025]
52 51 READPROG_LOWER Reading progress measure - lower confidence limit [not populated in 2025]
53 52 READPROG_UPPER Reading progress measure - upper confidence limit [not populated in 2025]
54 53 READCOV Reading progress measure - coverage [not populated in 2025]
55 54 WRITPROG Writing progress measure [not populated in 2025]
56 55 WRITPROG_LOWER Writing progress measure - lower confidence limit [not populated in 2025]
57 56 WRITPROG_UPPER Writing progress measure - upper confidence limit [not populated in 2025]
58 57 WRITCOV Writing progress measure - coverage [not populated in 2025]
59 58 MATPROG Maths progress measure [not populated in 2025]
60 59 MATPROG_LOWER Maths progress measure - lower confidence limit [not populated in 2025]
61 60 MATPROG_UPPER Maths progress measure - upper confidence limit [not populated in 2025]
62 61 MATCOV Maths progress measure - coverage [not populated in 2025]
63 62 PTREAD_EXP Percentage of pupils reaching the expected standard in reading
64 63 PTREAD_HIGH Percentage of pupils achieving a high score in reading
65 64 PTREAD_AT Percentage of pupils absent from or not able to access the test in reading
66 65 READ_AVERAGE Average scaled score in reading
67 66 PTGPS_EXP Percentage of pupils reaching the expected standard in grammar, punctuation and spelling
68 67 PTGPS_HIGH Percentage of pupils achieving a high score in grammar, punctuation and spelling
69 68 PTGPS_AT Percentage of pupils absent from or not able to access the test in grammar, punctuation and spelling
70 69 GPS_AVERAGE Average scaled score in grammar, punctuation and spelling
71 70 PTMAT_EXP Percentage of pupils reaching the expected standard in maths
72 71 PTMAT_HIGH Percentage of pupils achieving a high score in maths
73 72 PTMAT_AT Percentage of pupils absent from or not able to access the test in maths
74 73 MAT_AVERAGE Average scaled score in maths
75 74 PTWRITTA_EXP Percentage of pupils reaching the expected standard in writing
76 75 PTWRITTA_HIGH Percentage of pupils working at greater depth within the expected standard in writing
77 76 PTWRITTA_WTS Percentage of pupils working towards the expected standard in writing
78 77 PTWRITTA_AD Percentage of pupils absent or disapplied in writing TA
79 78 PTSCITA_EXP Percentage of pupils reaching the expected standard in science TA
80 79 PTSCITA_AD Percentage of pupils absent or disapplied in science TA
81 80 PTRWM_EXP_B Percentage of boys reaching the expected standard in reading, writing and maths
82 81 PTRWM_EXP_G Percentage of girls reaching the expected standard in reading, writing and maths
83 82 PTRWM_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]
84 83 PTRWM_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]
85 84 PTRWM_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]
86 85 PTRWM_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths
87 86 PTRWM_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths
88 87 DIFFN_RWM_EXP Difference between school percentage of disavantaged pupils and national percentage of other pupils reaching the expected standard in reading, writing and maths
89 88 PTRWM_EXP_EAL Percentage of EAL pupils reaching the expected standard in reading, writing and maths
90 89 PTRWM_EXP_MOBN Percentage of non-mobile pupils reaching the expected standard in reading, writing and maths
91 90 PTRWM_HIGH_B Percentage of boys achieving a high score in reading and maths and working at greater depth in writing
92 91 PTRWM_HIGH_G Percentage of girls reaching the HIGHected standard in reading, writing and maths
93 92 PTRWM_HIGH_L Percentage of pupils with low prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
94 93 PTRWM_HIGH_M Percentage of pupils with medium prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
95 94 PTRWM_HIGH_H Percentage of pupils with high prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
96 95 PTRWM_HIGH_FSM6CLA1A Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
97 96 PTRWM_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
98 97 DIFFN_RWM_HIGH Difference between school percentage of disavantaged pupils and national percentage of other pupils achieving a high score in reading, writing and maths
99 98 PTRWM_HIGH_EAL Percentage of EAL pupils achieving a high score in reading and maths and working at greater depth in writing
100 99 PTRWM_HIGH_MOBN Percentage of non-mobile pupils achieving a high score in reading and maths and working at greater depth in writing
101 100 READPROG_B Reading progress measure for boys [not populated in 2025]
102 101 READPROG_B_LOWER Reading progress measure for boys - lower confidence limit [not populated in 2025]
103 102 READPROG_B_UPPER Reading progress measure for boys - upper confidence limit [not populated in 2025]
104 103 READPROG_G Reading progress measure for girls [not populated in 2025]
105 104 READPROG_G_LOWER Reading progress measure for girls - lower confidence limit [not populated in 2025]
106 105 READPROG_G_UPPER Reading progress measure for girls - upper confidence limit [not populated in 2025]
107 106 READPROG_L Reading progress measure for pupils with low prior attainment [not populated in 2025]
108 107 READPROG_L_LOWER Reading progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
109 108 READPROG_L_UPPER Reading progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
110 109 READPROG_M Reading progress measure for pupils with medium prior attainment [not populated in 2025]
111 110 READPROG_M_LOWER Reading progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
112 111 READPROG_M_UPPER Reading progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
113 112 READPROG_H Reading progress measure for pupils with high prior attainment [not populated in 2025]
114 113 READPROG_H_LOWER Reading progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
115 114 READPROG_H_UPPER Reading progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
116 115 READPROG_FSM6CLA1A Reading progress measure for disadvantaged pupils [not populated in 2025]
117 116 READPROG_FSM6CLA1A_LOWER Reading progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
118 117 READPROG_FSM6CLA1A_UPPER Reading progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
119 118 READPROG_NotFSM6CLA1A Reading progress measure for non-disadvantaged pupils [not populated in 2025]
120 119 READPROG_NotFSM6CLA1A_LOWER Reading progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
121 120 READPROG_NotFSM6CLA1A_UPPER Reading progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
122 121 DIFFN_READPROG Difference between reading progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
123 122 READPROG_EAL Reading progress measure for EAL pupils [not populated in 2025]
124 123 READPROG_EAL_LOWER Reading progress measure for EAL pupils - lower confidence limit [not populated in 2025]
125 124 READPROG_EAL_UPPER Reading progress measure for EAL pupils - upper confidence limit [not populated in 2025]
126 125 READPROG_MOBN Reading progress measure for non-mobile pupils [not populated in 2025]
127 126 READPROG_MOBN_LOWER Reading progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
128 127 READPROG_MOBN_UPPER Reading progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
129 128 WRITPROG_B Writing progress measure for boys [not populated in 2025]
130 129 WRITPROG_B_LOWER Writing progress measure for boys - lower confidence limit [not populated in 2025]
131 130 WRITPROG_B_UPPER Writing progress measure for boys - upper confidence limit [not populated in 2025]
132 131 WRITPROG_G Writing progress measure for girls [not populated in 2025]
133 132 WRITPROG_G_LOWER Writing progress measure for girls - lower confidence limit [not populated in 2025]
134 133 WRITPROG_G_UPPER Writing progress measure for girls - upper confidence limit [not populated in 2025]
135 134 WRITPROG_L Writing progress measure for pupils with low prior attainment [not populated in 2025]
136 135 WRITPROG_L_LOWER Writing progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
137 136 WRITPROG_L_UPPER Writing progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
138 137 WRITPROG_M Writing progress measure for pupils with medium prior attainment [not populated in 2025]
139 138 WRITPROG_M_LOWER Writing progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
140 139 WRITPROG_M_UPPER Writing progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
141 140 WRITPROG_H Writing progress measure for pupils with high prior attainment [not populated in 2025]
142 141 WRITPROG_H_LOWER Writing progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
143 142 WRITPROG_H_UPPER Writing progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
144 143 WRITPROG_FSM6CLA1A Writing progress measure for disadvantaged pupils [not populated in 2025]
145 144 WRITPROG_FSM6CLA1A_LOWER Writing progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
146 145 WRITPROG_FSM6CLA1A_UPPER Writing progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
147 146 WRITPROG_NotFSM6CLA1A Writing progress measure for non-disadvantaged pupils [not populated in 2025]
148 147 WRITPROG_NotFSM6CLA1A_LOWER Writing progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
149 148 WRITPROG_NotFSM6CLA1A_UPPER Writing progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
150 149 DIFFN_WRITPROG Difference between writing progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
151 150 WRITPROG_EAL Writing progress measure for EAL pupils [not populated in 2025]
152 151 WRITPROG_EAL_LOWER Writing progress measure for EAL pupils - lower confidence limit [not populated in 2025]
153 152 WRITPROG_EAL_UPPER Writing progress measure for EAL pupils - upper confidence limit [not populated in 2025]
154 153 WRITPROG_MOBN Writing progress measure for non-mobile pupils [not populated in 2025]
155 154 WRITPROG_MOBN_LOWER Writing progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
156 155 WRITPROG_MOBN_UPPER Writing progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
157 156 MATPROG_B Maths progress measure for boys [not populated in 2025]
158 157 MATPROG_B_LOWER Maths progress measure for boys - lower confidence limit [not populated in 2025]
159 158 MATPROG_B_UPPER Maths progress measure for boys - upper confidence limit [not populated in 2025]
160 159 MATPROG_G Maths progress measure for girls [not populated in 2025]
161 160 MATPROG_G_LOWER Maths progress measure for girls - lower confidence limit [not populated in 2025]
162 161 MATPROG_G_UPPER Maths progress measure for girls - upper confidence limit [not populated in 2025]
163 162 MATPROG_L Maths progress measure for pupils with low prior attainment [not populated in 2025]
164 163 MATPROG_L_LOWER Maths progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
165 164 MATPROG_L_UPPER Maths progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
166 165 MATPROG_M Maths progress measure for pupils with medium prior attainment [not populated in 2025]
167 166 MATPROG_M_LOWER Maths progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
168 167 MATPROG_M_UPPER Maths progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
169 168 MATPROG_H Maths progress measure for pupils with high prior attainment [not populated in 2025]
170 169 MATPROG_H_LOWER Maths progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
171 170 MATPROG_H_UPPER Maths progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
172 171 MATPROG_FSM6CLA1A Maths progress measure for disadvantaged pupils [not populated in 2025]
173 172 MATPROG_FSM6CLA1A_LOWER Maths progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
174 173 MATPROG_FSM6CLA1A_UPPER Maths progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
175 174 MATPROG_NotFSM6CLA1A Maths progress measure for non-disadvantaged pupils [not populated in 2025]
176 175 MATPROG_NotFSM6CLA1A_LOWER Maths progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
177 176 MATPROG_NotFSM6CLA1A_UPPER Maths progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
178 177 DIFFN_MATPROG Difference between maths progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
179 178 MATPROG_EAL Maths progress measure for EAL pupils [not populated in 2025]
180 179 MATPROG_EAL_LOWER Maths progress measure for EAL pupils - lower confidence limit [not populated in 2025]
181 180 MATPROG_EAL_UPPER Maths progress measure for EAL pupils - upper confidence limit [not populated in 2025]
182 181 MATPROG_MOBN Maths progress measure for non-mobile pupils [not populated in 2025]
183 182 MATPROG_MOBN_LOWER Maths progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
184 183 MATPROG_MOBN_UPPER Maths progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
185 184 READ_AVERAGE_B Average scaled score in reading for boys
186 185 READ_AVERAGE_G Average scaled score in reading for girls
187 186 READ_AVERAGE_L Average scaled score in reading for pupils with low prior attainment [not populated in 2025]
188 187 READ_AVERAGE_M Average scaled score in reading for pupils with medium prior attainment [not populated in 2025]
189 188 READ_AVERAGE_H Average scaled score in reading for pupils with high prior attainment [not populated in 2025]
190 189 READ_AVERAGE_FSM6CLA1A Average scaled score in reading for disadvantaged pupils
191 190 READ_AVERAGE_NotFSM6CLA1A Average scaled score in reading for non-disadvantaged pupils
192 191 READ_AVERAGE_EAL Average scaled score in reading for EAL pupils
193 192 READ_AVERAGE_MOBN Average scaled score in reading for MOBN pupils
194 193 MAT_AVERAGE_B Average scaled score in maths for boys
195 194 MAT_AVERAGE_G Average scaled score in maths for girls
196 195 MAT_AVERAGE_L Average scaled score in maths for pupils with low prior attainment [not populated in 2025]
197 196 MAT_AVERAGE_M Average scaled score in maths for pupils with medium prior attainment [not populated in 2025]
198 197 MAT_AVERAGE_H Average scaled score in maths for pupils with high prior attainment [not populated in 2025]
199 198 MAT_AVERAGE_FSM6CLA1A Average scaled score in maths for disadvantaged pupils
200 199 MAT_AVERAGE_NotFSM6CLA1A Average scaled score in maths for non-disadvantaged pupils
201 200 MAT_AVERAGE_EAL Average scaled score in maths for EAL pupils
202 201 MAT_AVERAGE_MOBN Average scaled score in maths for MOBN pupils
203 202 GPS_AVERAGE_B Average scaled score in GPS for boys
204 203 GPS_AVERAGE_G Average scaled score in GPS for girls
205 204 GPS_AVERAGE_L Average scaled score in GPS for pupils with low prior attainment [not populated in 2025]
206 205 GPS_AVERAGE_M Average scaled score in GPS for pupils with medium prior attainment [not populated in 2025]
207 206 GPS_AVERAGE_H Average scaled score in GPS for pupils with high prior attainment [not populated in 2025]
208 207 GPS_AVERAGE_FSM6CLA1A Average scaled score in GPS for disadvantaged pupils
209 208 GPS_AVERAGE_NotFSM6CLA1A Average scaled score in GPS for non-disadvantaged pupils
210 209 GPS_AVERAGE_EAL Average scaled score in GPS for EAL pupils
211 210 GPS_AVERAGE_MOBN Average scaled score in GPS for MOBN pupils
212 211 PTREAD_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in reading [not populated in 2025]
213 212 PTREAD_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in reading [not populated in 2025]
214 213 PTREAD_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in reading [not populated in 2025]
215 214 PTREAD_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in reading
216 215 PTREAD_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in reading
217 216 PTGPS_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]
218 217 PTGPS_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]
219 218 PTGPS_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]
220 219 PTGPS_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling
221 220 PTGPS_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling
222 221 PTMAT_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in maths [not populated in 2025]
223 222 PTMAT_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in maths [not populated in 2025]
224 223 PTMAT_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in maths [not populated in 2025]
225 224 PTMAT_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in maths
226 225 PTMAT_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in maths
227 226 PTWRITTA_EXP_L Percentage of pupils with low prior attainment reaching the expected standard in writing [not populated in 2025]
228 227 PTWRITTA_EXP_M Percentage of pupils with medium prior attainment reaching the expected standard in writing [not populated in 2025]
229 228 PTWRITTA_EXP_H Percentage of pupils with high prior attainment reaching the expected standard in writing [not populated in 2025]
230 229 PTWRITTA_EXP_FSM6CLA1A Percentage of disadvantaged pupils reaching the expected standard in writing
231 230 PTWRITTA_EXP_NotFSM6CLA1A Percentage of non-disadvantaged pupils reaching the expected standard in writing
232 231 PTREAD_HIGH_L Percentage of pupils with low prior attainment achieving a high score in reading [not populated in 2025]
233 232 PTREAD_HIGH_M Percentage of pupils with medium prior attainment achieving a high score in reading [not populated in 2025]
234 233 PTREAD_HIGH_H Percentage of pupils with high prior attainment achieving a high score in reading [not populated in 2025]
235 234 PTREAD_HIGH_FSM6CLA1A Percentage of disadvantaged pupils achieving a high score in reading
236 235 PTREAD_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils achieving a high score in reading
237 236 PTGPS_HIGH_L Percentage of pupils with low prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]
238 237 PTGPS_HIGH_M Percentage of pupils with medium prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]
239 238 PTGPS_HIGH_H Percentage of pupils with high prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]
240 239 PTGPS_HIGH_FSM6CLA1A Percentage of disadvantaged pupils achieving a high score in grammar, punctuation and spelling
241 240 PTGPS_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils achieving a high score in grammar, punctuation and spelling
242 241 PTMAT_HIGH_L Percentage of pupils with low prior attainment achieving a high score in maths [not populated in 2025]
243 242 PTMAT_HIGH_M Percentage of pupils with medium prior attainment achieving a high score in maths [not populated in 2025]
244 243 PTMAT_HIGH_H Percentage of pupils with high prior attainment achieving a high score in maths [not populated in 2025]
245 244 PTMAT_HIGH_FSM6CLA1A Percentage of disadvantaged pupils achieving a high score in maths
246 245 PTMAT_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils achieving a high score in maths
247 246 PTWRITTA_HIGH_L Percentage of pupils with low prior attainment working at greater depth in writing [not populated in 2025]
248 247 PTWRITTA_HIGH_M Percentage of pupils with medium prior attainment working at greater depth in writing [not populated in 2025]
249 248 PTWRITTA_HIGH_H Percentage of pupils with high prior attainment working at greater depth in writing [not populated in 2025]
250 249 PTWRITTA_HIGH_FSM6CLA1A Percentage of disadvantaged pupils working at greater depth in writing
251 250 PTWRITTA_HIGH_NotFSM6CLA1A Percentage of non-disadvantaged pupils working at greater depth in writing
252 251 TEALGRP1 Number of eligible pupils with English as first language
253 252 PTEALGRP1 Percentage of eligible pupils with English as first language
254 253 TEALGRP3 Number of eligible pupils with unclassified language
255 254 PTEALGRP3 Percentage of eligible pupils with unclassified language
256 255 TSENELE Number of eligible pupils with EHC plan
257 256 PSENELE Percentage of eligible pupils with EHC plan
258 257 TSENELK Number of eligible pupils with SEN support
259 258 PSENELK Percentage of eligible pupils with SEN support
260 259 TSENELEK Number of eligible pupils with SEN (EHC plan or SEN support)
261 260 PSENELEK Percentage of eligible pupils with SEN (EHC plan or SEN support)
262 261 TELIG_24 Number of eligible pupils 2024
263 262 PTFSM6CLA1A_24 Percentage of key stage 2 disadvantaged pupils one year prior
264 263 PTNOTFSM6CLA1A_24 Percentage of key stage 2 pupils who are not disadvantaged one year prior
265 264 PTRWM_EXP_24 Percentage of pupils reaching the expected standard in reading, writing and maths one year prior
266 265 PTRWM_HIGH_24 Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
267 266 PTRWM_EXP_FSM6CLA1A_24 Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior
268 267 PTRWM_HIGH_FSM6CLA1A_24 Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
269 268 PTRWM_EXP_NotFSM6CLA1A_24 Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior
270 269 PTRWM_HIGH_NotFSM6CLA1A_24 Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
271 270 READPROG_24 Reading progress measure - one year prior [not populated in 2025]
272 271 READPROG_LOWER_24 Reading progress measure - lower confidence limit - one year prior [not populated in 2025]
273 272 READPROG_UPPER_24 Reading progress measure - upper confidence limit - one year prior [not populated in 2025]
274 273 WRITPROG_24 Writing progress measure - one year prior [not populated in 2025]
275 274 WRITPROG_LOWER_24 Writing progress measure - lower confidence limit - one year prior [not populated in 2025]
276 275 WRITPROG_UPPER_24 Writing progress measure - upper confidence limit - one year prior [not populated in 2025]
277 276 MATPROG_24 Maths progress measure - one year prior [not populated in 2025]
278 277 MATPROG_LOWER_24 Maths progress measure - lower confidence limit - one year prior [not populated in 2025]
279 278 MATPROG_UPPER_24 Maths progress measure - upper confidence limit - one year prior [not populated in 2025]
280 279 READ_AVERAGE_24 Average scaled score in reading - one year prior
281 280 MAT_AVERAGE_24 Average scaled score in maths - one year prior
282 281 TELIG_23 Number of eligible pupils 2023
283 282 PTFSM6CLA1A_23 Percentage of key stage 2 disadvantaged pupils - two years prior
284 283 PTNOTFSM6CLA1A_23 Percentage of key stage 2 pupils who are not disadvantaged - two years prior
285 284 PTRWM_EXP_23 Percentage of pupils reaching the expected standard in reading, writing and maths - two years prior
286 285 PTRWM_HIGH_23 Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
287 286 PTRWM_EXP_FSM6CLA1A_23 Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior
288 287 PTRWM_HIGH_FSM6CLA1A_23 Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
289 288 PTRWM_EXP_NotFSM6CLA1A_23 Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior
290 289 PTRWM_HIGH_NotFSM6CLA1A_23 Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
291 290 READPROG_23 Reading progress measure - two years prior
292 291 READPROG_LOWER_23 Reading progress measure - lower confidence limit - two years prior
293 292 READPROG_UPPER_23 Reading progress measure - upper confidence limit - two years prior
294 293 WRITPROG_23 Writing progress measure - two years prior
295 294 WRITPROG_LOWER_23 Writing progress measure - lower confidence limit - two years prior
296 295 WRITPROG_UPPER_23 Writing progress measure - upper confidence limit - two years prior
297 296 MATPROG_23 Maths progress measure - two years prior
298 297 MATPROG_LOWER_23 Maths progress measure - lower confidence limit - two years prior
299 298 MATPROG_UPPER_23 Maths progress measure - upper confidence limit - two years prior
300 299 READ_AVERAGE_23 Average scaled score in reading - two years prior
301 300 MAT_AVERAGE_23 Average scaled score in maths - two years prior
302 301 TELIG_3YR Total number of pupils at the end of Key Stage 2 over the past three years
303 302 PTRWM_EXP_3YR Percentage of pupils reaching the expected standard in reading, writing and maths - 3 year total
304 303 PTRWM_HIGH_3YR Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - 3 year total
305 304 READ_AVERAGE_3YR Average scaled score in reading - 3 year average
306 305 MAT_AVERAGE_3YR Average scaled score in maths - 3 year average
307 306 READPROG_UNADJUSTED Unadjusted reading progress measure [not populated in 2025]
308 307 WRITPROG_UNADJUSTED Unadjusted writing progress measure [not populated in 2025]
309 308 MATPROG_UNADJUSTED Unadjusted maths progress measure [not populated in 2025]
310 309 READPROG_DESCR Reading progress measure 'description' [not populated in 2025]
311 310 WRITPROG_DESCR Writing progress measure 'description' [not populated in 2025]
312 311 MATPROG_DESCR Maths progress measure 'description' [not populated in 2025]
-154
View File
@@ -1,154 +0,0 @@
LEA,LA Name,REGION,REGION NAME
841,Darlington,1,North East A
840,County Durham,1,North East A
805,Hartlepool,1,North East A
806,Middlesbrough,1,North East A
807,Redcar and Cleveland,1,North East A
808,Stockton-on-Tees,1,North East A
390,Gateshead,3,North East B
391,Newcastle upon Tyne,3,North East B
392,North Tyneside,3,North East B
929,Northumberland,3,North East B
393,South Tyneside,3,North East B
394,Sunderland,3,North East B
889,Blackburn with Darwen,6,North West A
890,Blackpool,6,North West A
942,Cumberland,6,North West A
943,Westmorland and Furness ,6,North West A
888,Lancashire,6,North West A
350,Bolton,7,North West B
351,Bury,7,North West B
352,Manchester,7,North West B
353,Oldham,7,North West B
354,Rochdale,7,North West B
355,Salford,7,North West B
356,Stockport,7,North West B
357,Tameside,7,North West B
358,Trafford,7,North West B
359,Wigan,7,North West B
895,Cheshire East,9,North West C
896,Cheshire West and Chester,9,North West C
876,Halton,9,North West C
340,Knowsley,9,North West C
341,Liverpool,9,North West C
343,Sefton,9,North West C
342,St. Helens,9,North West C
877,Warrington,9,North West C
344,Wirral,9,North West C
811,East Riding of Yorkshire,10,North Yorkshire and The Humber
810,"Kingston Upon Hull, City of",10,North Yorkshire and The Humber
812,North East Lincolnshire,10,North Yorkshire and The Humber
813,North Lincolnshire,10,North Yorkshire and The Humber
815,North Yorkshire,10,North Yorkshire and The Humber
816,York,10,North Yorkshire and The Humber
370,Barnsley,12,South and West Yorkshire
380,Bradford,12,South and West Yorkshire
381,Calderdale,12,South and West Yorkshire
371,Doncaster,12,South and West Yorkshire
382,Kirklees,12,South and West Yorkshire
383,Leeds,12,South and West Yorkshire
372,Rotherham,12,South and West Yorkshire
373,Sheffield,12,South and West Yorkshire
384,Wakefield,12,South and West Yorkshire
831,Derby,14,East Midlands A
830,Derbyshire,14,East Midlands A
892,Nottingham,14,East Midlands A
891,Nottinghamshire,14,East Midlands A
856,Leicester,16,East Midlands B
855,Leicestershire,16,East Midlands B
925,Lincolnshire,16,East Midlands B
940,North Northamptonshire,16,East Midlands B
941,West Northamptonshire,16,East Midlands B
857,Rutland,16,East Midlands B
893,Shropshire,20,West Midlands A
860,Staffordshire,20,West Midlands A
861,Stoke-on-Trent,20,West Midlands A
894,Telford and Wrekin,20,West Midlands A
884,"Herefordshire, County of",22,West Midlands B
885,Worcestershire,22,West Midlands B
330,Birmingham,24,West Midlands C
331,Coventry,24,West Midlands C
332,Dudley,24,West Midlands C
333,Sandwell,24,West Midlands C
334,Solihull,24,West Midlands C
335,Walsall,24,West Midlands C
937,Warwickshire,24,West Midlands C
336,Wolverhampton,24,West Midlands C
822,Bedford,25,East of England A
873,Cambridgeshire,25,East of England A
823,Central Bedfordshire,25,East of England A
919,Hertfordshire,25,East of England A
821,Luton,25,East of England A
874,Peterborough,25,East of England A
881,Essex,27,East of England B
926,Norfolk,27,East of England B
882,Southend-on-Sea,27,East of England B
935,Suffolk,27,East of England B
883,Thurrock,27,East of England B
202,Camden,31,London Central
206,Islington,31,London Central
207,Kensington and Chelsea,31,London Central
208,Lambeth,31,London Central
210,Southwark,31,London Central
212,Wandsworth,31,London Central
213,Westminster,31,London Central
301,Barking and Dagenham,32,London East
303,Bexley,32,London East
201,City of London,32,London East
203,Greenwich,32,London East
204,Hackney,32,London East
311,Havering,32,London East
209,Lewisham,32,London East
316,Newham,32,London East
317,Redbridge,32,London East
211,Tower Hamlets,32,London East
302,Barnet,33,London North
308,Enfield,33,London North
309,Haringey,33,London North
320,Waltham Forest,33,London North
305,Bromley,34,London South
306,Croydon,34,London South
314,Kingston upon Thames,34,London South
315,Merton,34,London South
318,Richmond upon Thames,34,London South
319,Sutton,34,London South
304,Brent,35,London West
307,Ealing,35,London West
205,Hammersmith and Fulham,35,London West
310,Harrow,35,London West
312,Hillingdon,35,London West
313,Hounslow,35,London West
867,Bracknell Forest,36,South East A
825,Buckinghamshire,36,South East A
826,Milton Keynes,36,South East A
931,Oxfordshire,36,South East A
870,Reading,36,South East A
871,Slough,36,South East A
869,West Berkshire,36,South East A
868,Windsor and Maidenhead,36,South East A
872,Wokingham,36,South East A
850,Hampshire,37,South East B
921,Isle of Wight,37,South East B
851,Portsmouth,37,South East B
852,Southampton,37,South East B
936,Surrey,38,South East C
938,West Sussex,38,South East C
846,Brighton and Hove,39,South East D
845,East Sussex,39,South East D
886,Kent,39,South East D
887,Medway,39,South East D
839,"Bournemouth, Christchurch and Poole",43,South West A
908,Cornwall,43,South West A
878,Devon,43,South West A
838,Dorset,43,South West A
420,Isles of Scilly,43,South West A
879,Plymouth,43,South West A
933,Somerset,43,South West A
880,Torbay,43,South West A
800,Bath and North East Somerset,45,South West B
801,"Bristol, City of",45,South West B
916,Gloucestershire,45,South West B
802,North Somerset,45,South West B
803,South Gloucestershire,45,South West B
866,Swindon,45,South West B
865,Wiltshire,45,South West B
1 LEA LA Name REGION REGION NAME
2 841 Darlington 1 North East A
3 840 County Durham 1 North East A
4 805 Hartlepool 1 North East A
5 806 Middlesbrough 1 North East A
6 807 Redcar and Cleveland 1 North East A
7 808 Stockton-on-Tees 1 North East A
8 390 Gateshead 3 North East B
9 391 Newcastle upon Tyne 3 North East B
10 392 North Tyneside 3 North East B
11 929 Northumberland 3 North East B
12 393 South Tyneside 3 North East B
13 394 Sunderland 3 North East B
14 889 Blackburn with Darwen 6 North West A
15 890 Blackpool 6 North West A
16 942 Cumberland 6 North West A
17 943 Westmorland and Furness 6 North West A
18 888 Lancashire 6 North West A
19 350 Bolton 7 North West B
20 351 Bury 7 North West B
21 352 Manchester 7 North West B
22 353 Oldham 7 North West B
23 354 Rochdale 7 North West B
24 355 Salford 7 North West B
25 356 Stockport 7 North West B
26 357 Tameside 7 North West B
27 358 Trafford 7 North West B
28 359 Wigan 7 North West B
29 895 Cheshire East 9 North West C
30 896 Cheshire West and Chester 9 North West C
31 876 Halton 9 North West C
32 340 Knowsley 9 North West C
33 341 Liverpool 9 North West C
34 343 Sefton 9 North West C
35 342 St. Helens 9 North West C
36 877 Warrington 9 North West C
37 344 Wirral 9 North West C
38 811 East Riding of Yorkshire 10 North Yorkshire and The Humber
39 810 Kingston Upon Hull, City of 10 North Yorkshire and The Humber
40 812 North East Lincolnshire 10 North Yorkshire and The Humber
41 813 North Lincolnshire 10 North Yorkshire and The Humber
42 815 North Yorkshire 10 North Yorkshire and The Humber
43 816 York 10 North Yorkshire and The Humber
44 370 Barnsley 12 South and West Yorkshire
45 380 Bradford 12 South and West Yorkshire
46 381 Calderdale 12 South and West Yorkshire
47 371 Doncaster 12 South and West Yorkshire
48 382 Kirklees 12 South and West Yorkshire
49 383 Leeds 12 South and West Yorkshire
50 372 Rotherham 12 South and West Yorkshire
51 373 Sheffield 12 South and West Yorkshire
52 384 Wakefield 12 South and West Yorkshire
53 831 Derby 14 East Midlands A
54 830 Derbyshire 14 East Midlands A
55 892 Nottingham 14 East Midlands A
56 891 Nottinghamshire 14 East Midlands A
57 856 Leicester 16 East Midlands B
58 855 Leicestershire 16 East Midlands B
59 925 Lincolnshire 16 East Midlands B
60 940 North Northamptonshire 16 East Midlands B
61 941 West Northamptonshire 16 East Midlands B
62 857 Rutland 16 East Midlands B
63 893 Shropshire 20 West Midlands A
64 860 Staffordshire 20 West Midlands A
65 861 Stoke-on-Trent 20 West Midlands A
66 894 Telford and Wrekin 20 West Midlands A
67 884 Herefordshire, County of 22 West Midlands B
68 885 Worcestershire 22 West Midlands B
69 330 Birmingham 24 West Midlands C
70 331 Coventry 24 West Midlands C
71 332 Dudley 24 West Midlands C
72 333 Sandwell 24 West Midlands C
73 334 Solihull 24 West Midlands C
74 335 Walsall 24 West Midlands C
75 937 Warwickshire 24 West Midlands C
76 336 Wolverhampton 24 West Midlands C
77 822 Bedford 25 East of England A
78 873 Cambridgeshire 25 East of England A
79 823 Central Bedfordshire 25 East of England A
80 919 Hertfordshire 25 East of England A
81 821 Luton 25 East of England A
82 874 Peterborough 25 East of England A
83 881 Essex 27 East of England B
84 926 Norfolk 27 East of England B
85 882 Southend-on-Sea 27 East of England B
86 935 Suffolk 27 East of England B
87 883 Thurrock 27 East of England B
88 202 Camden 31 London Central
89 206 Islington 31 London Central
90 207 Kensington and Chelsea 31 London Central
91 208 Lambeth 31 London Central
92 210 Southwark 31 London Central
93 212 Wandsworth 31 London Central
94 213 Westminster 31 London Central
95 301 Barking and Dagenham 32 London East
96 303 Bexley 32 London East
97 201 City of London 32 London East
98 203 Greenwich 32 London East
99 204 Hackney 32 London East
100 311 Havering 32 London East
101 209 Lewisham 32 London East
102 316 Newham 32 London East
103 317 Redbridge 32 London East
104 211 Tower Hamlets 32 London East
105 302 Barnet 33 London North
106 308 Enfield 33 London North
107 309 Haringey 33 London North
108 320 Waltham Forest 33 London North
109 305 Bromley 34 London South
110 306 Croydon 34 London South
111 314 Kingston upon Thames 34 London South
112 315 Merton 34 London South
113 318 Richmond upon Thames 34 London South
114 319 Sutton 34 London South
115 304 Brent 35 London West
116 307 Ealing 35 London West
117 205 Hammersmith and Fulham 35 London West
118 310 Harrow 35 London West
119 312 Hillingdon 35 London West
120 313 Hounslow 35 London West
121 867 Bracknell Forest 36 South East A
122 825 Buckinghamshire 36 South East A
123 826 Milton Keynes 36 South East A
124 931 Oxfordshire 36 South East A
125 870 Reading 36 South East A
126 871 Slough 36 South East A
127 869 West Berkshire 36 South East A
128 868 Windsor and Maidenhead 36 South East A
129 872 Wokingham 36 South East A
130 850 Hampshire 37 South East B
131 921 Isle of Wight 37 South East B
132 851 Portsmouth 37 South East B
133 852 Southampton 37 South East B
134 936 Surrey 38 South East C
135 938 West Sussex 38 South East C
136 846 Brighton and Hove 39 South East D
137 845 East Sussex 39 South East D
138 886 Kent 39 South East D
139 887 Medway 39 South East D
140 839 Bournemouth, Christchurch and Poole 43 South West A
141 908 Cornwall 43 South West A
142 878 Devon 43 South West A
143 838 Dorset 43 South West A
144 420 Isles of Scilly 43 South West A
145 879 Plymouth 43 South West A
146 933 Somerset 43 South West A
147 880 Torbay 43 South West A
148 800 Bath and North East Somerset 45 South West B
149 801 Bristol, City of 45 South West B
150 916 Gloucestershire 45 South West B
151 802 North Somerset 45 South West B
152 803 South Gloucestershire 45 South West B
153 866 Swindon 45 South West B
154 865 Wiltshire 45 South West B
+214
View File
@@ -0,0 +1,214 @@
# Portainer Stack Definition for School Compare — STAGING
#
# Deploy this as a *separate* Portainer stack (e.g. "schoolcompare-staging")
# alongside the production stack. Differences from production:
# - images pinned to :staging (pushed by every merge to main, before the E2E gate)
# - sc_staging_* container names
# - own macvlan IPs (STAGING_DB_IP / STAGING_FRONTEND_IP env vars)
# - Airflow UI published on 8081 (prod uses 8080)
# - volumes are isolated automatically: Portainer prefixes volume names with
# the stack name, so this stack gets its own postgres/typesense/airflow data
#
# Portainer environment variables (set in Portainer UI -> Stack -> Environment):
# DB_USERNAME — PostgreSQL username
# DB_PASSWORD — PostgreSQL password
# DB_DATABASE_NAME — PostgreSQL database name
# ADMIN_API_KEY — Backend admin API key
# TYPESENSE_API_KEY — Typesense admin API key
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
# STAGING_DB_IP — macvlan IP for staging Postgres (default 10.0.1.190)
# STAGING_FRONTEND_IP — macvlan IP for staging frontend (default 10.0.1.151)
services:
# ── PostgreSQL ────────────────────────────────────────────────────────
sc_database:
container_name: sc_staging_postgres
image: postgis/postgis:18-3.6-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
volumes:
- postgres_data:/var/lib/postgresql
shm_size: 128mb
networks:
backend: {}
macvlan:
ipv4_address: ${STAGING_DB_IP:-10.0.1.190}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
# ── FastAPI Backend ───────────────────────────────────────────────────
backend:
image: privaterepo.sitaru.org/tudor/school_compare-backend:staging
container_name: sc_staging_backend
environment:
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
PYTHONUNBUFFERED: 1
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
TYPESENSE_URL: http://typesense:8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
depends_on:
sc_database:
condition: service_healthy
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/api/data-info"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# ── Next.js Frontend ──────────────────────────────────────────────────
frontend:
image: privaterepo.sitaru.org/tudor/school_compare-frontend:staging
container_name: sc_staging_nextjs
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=http://localhost:8000/api
- FASTAPI_URL=http://backend:80/api
- TYPESENSE_URL=http://typesense:8108
- TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme}
depends_on:
backend:
condition: service_healthy
networks:
backend: {}
macvlan:
ipv4_address: ${STAGING_FRONTEND_IP:-10.0.1.151}
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ── Typesense Search Engine ───────────────────────────────────────────
typesense:
image: typesense/typesense:30.1
container_name: sc_staging_typesense
environment:
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
TYPESENSE_DATA_DIR: /data
volumes:
- typesense_data:/data
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "cat < /dev/tcp/localhost/8108"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
# ── Airflow API Server + UI (staging: http://<host>:8081) ─────────────
airflow-api-server:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
container_name: sc_staging_airflow_api
command: airflow api-server --port 8080
ports:
- "8081:8080"
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
PG_HOST: sc_database
PG_PORT: "5432"
PG_USER: ${DB_USERNAME}
PG_PASSWORD: ${DB_PASSWORD}
PG_DATABASE: ${DB_DATABASE_NAME}
TYPESENSE_URL: http://typesense:8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
volumes:
- airflow_logs:/opt/airflow/logs
depends_on:
sc_database:
condition: service_healthy
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v2/monitor/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
# ── Airflow Scheduler ──────────────────────────────────────────────
airflow-scheduler:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
container_name: sc_staging_airflow_scheduler
command: airflow scheduler
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
PG_HOST: sc_database
PG_PORT: "5432"
PG_USER: ${DB_USERNAME}
PG_PASSWORD: ${DB_PASSWORD}
PG_DATABASE: ${DB_DATABASE_NAME}
TYPESENSE_URL: http://typesense:8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
volumes:
- airflow_logs:/opt/airflow/logs
depends_on:
sc_database:
condition: service_healthy
networks:
- backend
restart: unless-stopped
# ── Airflow DB Init (one-shot) ───────────────────────────────────────
airflow-init:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
container_name: sc_staging_airflow_init
command: bash -c "airflow db migrate && airflow dags reserialize"
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
AIRFLOW__CORE__DAGS_FOLDER: /opt/pipeline/dags
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-api-server:8080/execution/
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
depends_on:
sc_database:
condition: service_healthy
networks:
- backend
restart: "no"
networks:
backend:
driver: bridge
macvlan:
external:
name: macvlan
volumes:
postgres_data:
typesense_data:
airflow_logs:
+5 -90
View File
@@ -8,8 +8,6 @@
# TYPESENSE_API_KEY — Typesense admin API key
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
# KESTRA_USER — Kestra UI username (optional)
# KESTRA_PASSWORD — Kestra UI password (optional)
services:
@@ -38,7 +36,7 @@ services:
# ── FastAPI Backend ───────────────────────────────────────────────────
backend:
image: privaterepo.sitaru.org/tudor/school_compare-backend:latest
image: privaterepo.sitaru.org/tudor/school_compare-backend:prod
container_name: schoolcompare_backend
environment:
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
@@ -61,7 +59,7 @@ services:
# ── Next.js Frontend ──────────────────────────────────────────────────
frontend:
image: privaterepo.sitaru.org/tudor/school_compare-frontend:latest
image: privaterepo.sitaru.org/tudor/school_compare-frontend:prod
container_name: schoolcompare_nextjs
environment:
- NODE_ENV=production
@@ -103,90 +101,9 @@ services:
retries: 5
start_period: 10s
# ── Kestra — workflow orchestrator (legacy, kept during migration) ────
kestra:
image: kestra/kestra:latest
container_name: schoolcompare_kestra
command: server standalone
ports:
- "8090:8080"
volumes:
- kestra_storage:/app/storage
environment:
KESTRA_CONFIGURATION: |
datasources:
postgres:
url: jdbc:postgresql://sc_database:5432/kestra
driverClassName: org.postgresql.Driver
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
kestra:
repository:
type: postgres
queue:
type: postgres
storage:
type: local
local:
base-path: /app/storage
depends_on:
sc_database:
condition: service_healthy
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8081/health | grep -q '\"status\":\"UP\"'"]
interval: 15s
timeout: 10s
retries: 10
start_period: 60s
# ── Kestra init (legacy, kept during migration) ──────────────────────
kestra-init:
image: privaterepo.sitaru.org/tudor/school_compare-kestra-init:latest
container_name: schoolcompare_kestra_init
environment:
KESTRA_URL: http://kestra:8080
KESTRA_USER: ${KESTRA_USER:-}
KESTRA_PASSWORD: ${KESTRA_PASSWORD:-}
depends_on:
kestra:
condition: service_healthy
networks:
- backend
restart: "no"
# ── Data integrator (legacy, kept during migration) ──────────────────
integrator:
image: privaterepo.sitaru.org/tudor/school_compare-integrator:latest
container_name: schoolcompare_integrator
ports:
- "8001:8001"
environment:
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
DATA_DIR: /data
BACKEND_URL: http://backend:80
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
PYTHONUNBUFFERED: 1
volumes:
- supplementary_data:/data
depends_on:
sc_database:
condition: service_healthy
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
# ── Airflow API Server + UI ───────────────────────────────────────────
airflow-api-server:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
container_name: schoolcompare_airflow_api
command: airflow api-server --port 8080
ports:
@@ -225,7 +142,7 @@ services:
# ── Airflow Scheduler ──────────────────────────────────────────────
airflow-scheduler:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
container_name: schoolcompare_airflow_scheduler
command: airflow scheduler
environment:
@@ -255,7 +172,7 @@ services:
# ── Airflow DB Init (one-shot) ───────────────────────────────────────
airflow-init:
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
container_name: schoolcompare_airflow_init
command: bash -c "airflow db migrate && airflow dags delete school_data_daily -y 2>/dev/null; airflow dags delete school_data_monthly_ofsted -y 2>/dev/null; airflow dags delete school_data_annual_ees -y 2>/dev/null; airflow dags reserialize"
environment:
@@ -282,7 +199,5 @@ networks:
volumes:
postgres_data:
kestra_storage:
supplementary_data:
typesense_data:
airflow_logs:
+3
View File
@@ -9,6 +9,7 @@ services:
POSTGRES_USER: schoolcompare
POSTGRES_PASSWORD: schoolcompare
POSTGRES_DB: schoolcompare
POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8"
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
@@ -119,6 +120,8 @@ services:
PG_DATABASE: schoolcompare
TYPESENSE_URL: http://typesense:8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
BACKEND_URL: http://backend:80
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
volumes:
depends_on:
+131
View File
@@ -0,0 +1,131 @@
# SDLC & Deployment Pipeline
SchoolCompare uses a fully automated staging → production pipeline on Gitea
Actions. AI writes the code on feature branches; the pipeline verifies every
change on a staging environment before promoting the exact same images to
production. Human input is directional only: feature requests, PR review if
desired, and intervention when a gate fails.
## The flow
```
feature branch (AI-authored)
│ PR to main
PR checks (.gitea/workflows/pr-checks.yml)
typecheck + unit tests + backend smoke + image builds (no push)
+ Claude code review posted as a PR comment (severe findings fail the check)
│ merge (branch protection requires green checks)
Deploy pipeline (.gitea/workflows/deploy.yml)
1. build & push images → tags sha-<sha>, staging
2. staging Portainer webhook → wait for staging health
3. Playwright E2E journeys against staging
4. retag sha-<sha> → :prod (same bytes — build once, promote the image)
previous :prod saved as :prod-previous
5. prod Portainer webhook → wait for prod health
```
Key principle: **build once, promote the exact image**. Production pins `:prod`,
which only moves after the E2E gate passes on staging. Nothing tags `:latest`
anymore.
## Branch & PR workflow
- `main` is protected: no direct pushes, PRs require green status checks.
- All work (human or AI) happens on feature branches → PR to `main`.
- Merging to `main` **is** the release action. If staging or the E2E gate
fails, production is untouched.
## Environments
| | Production | Staging |
|---|---|---|
| Portainer stack file | `docker-compose.portainer.yml` | `docker-compose.portainer.staging.yml` |
| Image tag | `:prod` | `:staging` |
| Container prefix | `sc_` / `schoolcompare_` | `sc_staging_` |
| Frontend macvlan IP | 10.0.1.150 | `STAGING_FRONTEND_IP` (default 10.0.1.151) |
| Postgres macvlan IP | 10.0.1.189 | `STAGING_DB_IP` (default 10.0.1.190) |
| Airflow UI port | 8080 | 8081 |
| Volumes | stack-prefixed | stack-prefixed (fully isolated) |
Staging gets `:staging` images on every merge to main — even ones that later
fail the E2E gate. That's the point: staging absorbs the risk.
## Gitea repository secrets
| Secret | Purpose |
|---|---|
| `REGISTRY_TOKEN` | push images to privaterepo.sitaru.org (already set) |
| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code subscription auth for the PR review — generate with `claude setup-token` on your machine |
| `PORTAINER_STAGING_WEBHOOK` | staging stack redeploy webhook URL |
| `PORTAINER_PROD_WEBHOOK` | production stack redeploy webhook URL |
| `STAGING_BASE_URL` | e.g. `http://10.0.1.151:3000` — health poll + E2E target |
| `PROD_BASE_URL` | e.g. `http://10.0.1.150:3000` — post-promotion health poll |
## One-time setup checklist
1. **Create the staging stack** in Portainer from
`docker-compose.portainer.staging.yml` (stack name e.g.
`schoolcompare-staging`). Set the same environment variables as prod plus
`STAGING_DB_IP` / `STAGING_FRONTEND_IP` if the defaults clash.
2. **Enable webhooks** on both stacks (Portainer → Stack → Webhook) and store
the URLs as `PORTAINER_STAGING_WEBHOOK` / `PORTAINER_PROD_WEBHOOK`. Remove
the old hardcoded webhook usage (now gone from the workflows).
3. **Add the remaining secrets** listed above in Gitea → repo → Settings →
Actions → Secrets.
4. **Protect `main`** in Gitea → Settings → Branches: require PRs, require the
pr-checks status checks (frontend, backend, builds, ai-review) to pass.
5. **Bootstrap staging data via Airflow** (no prod dump — staging populates
itself from source, exercising the pipeline image end-to-end):
- Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order:
`school_data_daily`, `school_data_monthly_ofsted`, then the manual-schedule
`school_data_annual_ees` and `school_data_annual_idaci`.
- First runs download from government sources (GIAS, Ofsted, EES, IDACI),
run dbt, and sync Typesense — expect the initial backfill to take a while.
- The scheduled DAGs then keep staging fresh exactly like prod.
6. **Switch the prod stack to `:prod` tags** — the repo's
`docker-compose.portainer.yml` is already updated; redeploy the prod stack
from it. Until the first pipeline run promotes an image, tag the current
images manually: `docker buildx imagetools create -t <image>:prod <image>:latest`
for each of the three images.
## Rollback
Every promotion first re-points `:prod-previous` at the outgoing `:prod`.
To roll back:
```bash
for img in backend frontend pipeline; do
docker buildx imagetools create \
-t privaterepo.sitaru.org/tudor/school_compare-$img:prod \
privaterepo.sitaru.org/tudor/school_compare-$img:prod-previous
done
curl -fsSk -X POST "$PORTAINER_PROD_WEBHOOK"
```
Or promote any older build directly: `imagetools create -t <image>:prod <image>:sha-<shortsha>`.
## E2E suite
Lives in `e2e/` (own package — CI installs it without the app's node_modules).
Journeys: home + name search, postcode search, school detail, two-school
comparison, rankings table. Run locally against any environment:
```bash
cd e2e && npm ci
BASE_URL=http://10.0.1.151:3000 npx playwright test
```
Tests assert data invariants (results exist, charts render), not exact
numbers, so scheduled data refreshes don't break the gate.
## AI code review
`scripts/ci/ai_review.py` pipes the PR diff through headless Claude Code
(`claude -p`, authenticated with the subscription OAuth token — no API
billing), posts the structured findings as a PR comment using the per-run
token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup
needed), and fails the check only when a finding is rated
**severe** (would break prod, leak data, or corrupt data). Minor findings are
informational and never block a merge.
@@ -0,0 +1,381 @@
# SchoolCompare UX/UI Audit Execution 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:** Execute the journey-led UX/UI audit of live schoolcompare.co.uk defined in `docs/superpowers/specs/2026-07-02-ux-audit-design.md`, producing a prioritized (P0P3) audit report at `docs/superpowers/specs/2026-07-02-ux-audit-report.md`.
**Architecture:** Five journey walk-throughs (traffic-ordered) at two viewports using Playwright browser tools against the live site, each producing a committed notes file with friction points, "works well" observations, and axe-core scan results. A cross-cutting cohesion pass compares components across pages. A final synthesis task converts notes into the prioritized report.
**Tech Stack:** Playwright MCP browser tools (`mcp__plugin_playwright_playwright__*`), axe-core 4.x injected from CDN, markdown notes committed to git.
## Global Constraints
- Audit the **live site** `https://schoolcompare.co.uk` — do not start any local server (CLAUDE.md).
- Viewports: **mobile 390×844 (primary)** and **desktop 1440×900**. Mobile findings weigh more (56% of traffic).
- A finding is valid only if it cites: a Nielsen/NN-g heuristic violation, a WCAG 2.2 AA failure, a mobile-usability standard, or an observed task-flow obstruction. No taste-only findings.
- Every finding: **evidence → argument (why it hurts parents) → recommendation → uplift indication** (which analytics number moves, direction, small/moderate/large band with reasoning — never invented percentages).
- Read-only with respect to the site: browse and inspect only; never submit forms that create/modify data (search and filter interactions are fine).
- Notes live in `docs/superpowers/specs/2026-07-02-ux-audit-notes/`; screenshots go to the session scratchpad (referenced by filename in notes, not committed).
- Analytics baseline for weighting (30 days): entries `/` 63%, `/compare` 20%, `/rankings` 6%; exits `/` 46%, `/compare` 32%, `/rankings` 13%; views `/` 52%, `/compare` 27%, `/rankings` 12%, `/admissions` 5%; school pages ~1% each (SEO long tail); 56% mobile.
**Note on tool schemas:** Playwright tools are deferred. Before first use in any task, load them:
`ToolSearch` with query `select:mcp__plugin_playwright_playwright__browser_navigate,mcp__plugin_playwright_playwright__browser_resize,mcp__plugin_playwright_playwright__browser_snapshot,mcp__plugin_playwright_playwright__browser_take_screenshot,mcp__plugin_playwright_playwright__browser_evaluate,mcp__plugin_playwright_playwright__browser_click,mcp__plugin_playwright_playwright__browser_type,mcp__plugin_playwright_playwright__browser_press_key,mcp__plugin_playwright_playwright__browser_console_messages`
---
### Task 1: Audit scaffolding + axe-core harness verified on the homepage
**Files:**
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/axe-snippet.js`
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/TEMPLATE.md`
**Interfaces:**
- Produces: `axe-snippet.js` — a self-contained async JS function body for `browser_evaluate` that loads axe-core from CDN (idempotent) and returns `{violationCount, violations: [{id, impact, description, nodes: count, sampleTargets}]}` filtered to WCAG 2.2 A/AA rules. All journey tasks run this verbatim on each page state.
- Produces: `TEMPLATE.md` — the notes-file structure every journey task copies.
- [ ] **Step 1: Write the axe harness snippet**
Create `docs/superpowers/specs/2026-07-02-ux-audit-notes/axe-snippet.js`:
```js
// Body for playwright browser_evaluate: () => { ...this content... }
// Loads axe-core 4.x from CDN (skips if already present), runs WCAG A/AA scan.
return (async () => {
if (!window.axe) {
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/axe-core@4.10.2/axe.min.js';
s.onload = resolve;
s.onerror = () => reject(new Error('axe failed to load'));
document.head.appendChild(s);
});
}
const results = await window.axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] }
});
return {
url: location.pathname,
violationCount: results.violations.length,
violations: results.violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.help,
nodes: v.nodes.length,
sampleTargets: v.nodes.slice(0, 3).map(n => n.target.join(' '))
}))
};
})();
```
- [ ] **Step 2: Write the notes template**
Create `docs/superpowers/specs/2026-07-02-ux-audit-notes/TEMPLATE.md`:
```markdown
# Journey N: <name> — audit notes
**Pages visited:** <paths>
**Viewports:** 390×844, 1440×900
## Task attempt log
<What was attempted, step by step, and where time/attention went. Note seconds-to-goal where measurable.>
## Friction points
For each:
- **F<N>. <short title>**
- Evidence: <screenshot filename(s), observed behaviour, axe rule id if applicable>
- Criterion violated: <heuristic / WCAG SC / mobile standard / task obstruction>
- Argument: <why this hurts a parent completing the task>
- Severity guess: <P0/P1/P2/P3 — provisional, finalized in synthesis>
## Works well — keep
- <observation, with why it works>
## Axe results
- <path> @ <viewport>: <violationCount> violations — <ids with impact>
## Manual WCAG spot checks
- Touch targets ≥24px on interactive elements: <pass/fail + examples>
- Keyboard: tab order, focus visibility (desktop only): <pass/fail + examples>
- Zoom 200% text reflow (desktop only): <pass/fail>
## Screenshots
- <filename>: <what it shows>
```
- [ ] **Step 3: Verify the harness against the live homepage**
Load Playwright tool schemas (see Global Constraints note). Then:
1. `browser_resize` to 390×844.
2. `browser_navigate` to `https://schoolcompare.co.uk/`.
3. `browser_evaluate` with the contents of `axe-snippet.js` as the function body.
Expected: a JSON result with `violationCount` (any number ≥ 0) and no thrown error. If the CDN is blocked, switch the `src` to `https://unpkg.com/axe-core@4.10.2/axe.min.js` in the file and re-verify.
- [ ] **Step 4: Take a baseline screenshot to confirm capture works**
`browser_take_screenshot` with filename `home-mobile-baseline.png`. Expected: file saved, path returned.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/
git commit -m "chore(audit): axe harness and notes template for UX audit"
```
---
### Task 2: Journey 1 — Home → find my school (63% of entries, 46% exits)
**Files:**
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-1-home-find-school.md` (copy structure from `TEMPLATE.md`)
**Interfaces:**
- Consumes: `axe-snippet.js` via `browser_evaluate`; `TEMPLATE.md` structure.
- Produces: `journey-1-home-find-school.md` — notes consumed by Task 8 synthesis.
- [ ] **Step 1: Mobile walk-through (390×844)**
1. `browser_resize` 390×844, `browser_navigate` `https://schoolcompare.co.uk/`.
2. `browser_snapshot` — record what is above the fold: is the search input visible without scrolling? What competes for attention? Screenshot `j1-home-mobile-fold.png`.
3. Scroll the full page (`browser_press_key` End or evaluate `window.scrollTo`), screenshot `j1-home-mobile-full.png`. Note content order: does featured/secondary content precede the primary task?
4. **Task attempt A (school by name):** type a real school name into search, e.g. `Welland Primary` (known from analytics). Record: keystrokes-to-results, result quality (is the right school first?), loading feedback, and taps needed to reach `/school/146678-welland-primary-school`. Screenshot the results state `j1-search-results-mobile.png`.
5. **Task attempt B (postcode):** return home, search a plausible postcode (e.g. `B91 3` area for Solihull, or any valid UK postcode like `SW1A 1AA`). Record: is postcode search discoverable/labelled? Distance shown? Sensible ordering? Screenshot `j1-postcode-results-mobile.png`.
6. Record time-to-school-page for both attempts against the spec's ~15s target.
7. Run axe snippet on: home (initial) and home (results visible). Record results.
- [ ] **Step 2: Desktop walk-through (1440×900)**
Repeat Step 1's navigation and task attempt A at 1440×900 (screenshots `j1-home-desktop-fold.png`, `j1-search-results-desktop.png`). Additionally: tab through the page with keyboard — record focus visibility and whether search → results → school link is keyboard-operable. Test 200% zoom (`browser_evaluate` `document.body.style.zoom` is NOT valid for this — instead resize to 720×450 which approximates 200% reflow at 1440) and note any loss of content/overlap.
- [ ] **Step 3: Write notes file**
Fill `journey-1-home-find-school.md` per template. Every friction point needs evidence + criterion + argument. Explicitly answer: "why do 46% of visitors exit at home?" — list the plausible causes observed.
- [ ] **Step 4: Commit**
```bash
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-1-home-find-school.md
git commit -m "docs(audit): journey 1 notes — home to school search"
```
---
### Task 3: Journey 2 — Cold landing on a school detail page (SEO long tail)
**Files:**
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-2-school-detail-cold.md`
**Interfaces:**
- Consumes: `axe-snippet.js`, `TEMPLATE.md`.
- Produces: `journey-2-school-detail-cold.md` for Task 8.
- [ ] **Step 1: Mobile cold landing (390×844)**
Navigate **directly** (no prior site context — this simulates a Google arrival) to `https://schoolcompare.co.uk/school/136916-the-castle-school`. Then assess in order:
1. First-screen orientation: within one screen, can a parent tell what site this is, what school this is, and what the site offers? Screenshot `j2-school-mobile-fold.png`.
2. Scroll the entire page. Screenshots at each major section (`j2-school-mobile-<section>.png`). For each data block (results, Ofsted, characteristics, admissions): is it comprehensible to a non-specialist? Is jargon (RWM, expected standard, progress scores) explained in place?
3. Next-step paths: is there an obvious "compare this school" and "schools near this one" action? How many taps to a comparison including this school? Record the exact path or its absence.
4. Repeat the cold landing for a contrasting school `https://schoolcompare.co.uk/school/146678-welland-primary-school` (different data availability) — note any layout breakage or missing-data handling. Screenshot anomalies only.
5. Run axe snippet on both school pages. Record results.
- [ ] **Step 2: Desktop pass (1440×900)**
Reload `.../136916-the-castle-school` at desktop. Screenshot `j2-school-desktop-fold.png`. Check: hero/map rendering, chart legibility, keyboard focus through interactive elements, link affordance (do school-page links look clickable?).
- [ ] **Step 3: Write notes file**
Fill `journey-2-school-detail-cold.md`. Explicitly answer: "a parent lands here from Google — what would make them stay and use the site rather than bounce back to search results?"
- [ ] **Step 4: Commit**
```bash
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-2-school-detail-cold.md
git commit -m "docs(audit): journey 2 notes — cold landing on school detail"
```
---
### Task 4: Journey 3 — Building a comparison (27% of views, 32% exits)
**Files:**
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-3-compare.md`
**Interfaces:**
- Consumes: `axe-snippet.js`, `TEMPLATE.md`.
- Produces: `journey-3-compare.md` for Task 8.
- [ ] **Step 1: Mobile walk-through (390×844)**
1. Navigate to `https://schoolcompare.co.uk/compare` **directly** (20% of sessions enter here). Screenshot empty state `j3-compare-empty-mobile.png`. Is the empty state instructive — does it tell a parent what to do first?
2. Add two schools via whatever mechanism the page offers (search within compare, or navigate to school pages and use their compare action — record which paths exist). Count taps from empty state to a two-school comparison. Screenshot `j3-compare-two-schools-mobile.png`.
3. Assess the comparison output on mobile: are charts/tables legible at 390px? Horizontal scrolling? Can you tell which school is which (colour + label, not colour alone — WCAG 1.4.1)? Are metric names explained?
4. Remove a school; add a third. Any state loss, confusing controls, or dead ends? Does the selection persist if you navigate away and back?
5. Run axe snippet on empty state and populated state. Record results.
- [ ] **Step 2: Desktop pass (1440×900)**
Repeat comparison-building at desktop. Screenshot `j3-compare-two-schools-desktop.png`. Keyboard-operate the add/remove flow; record focus behaviour. Check chart tooltips/legends for mouse-only interactions.
- [ ] **Step 3: Write notes file**
Fill `journey-3-compare.md`. Explicitly answer: "compare is 32% of exits — is that task-complete satisfaction (fine) or abandonment (problem)? What observed evidence points either way?"
- [ ] **Step 4: Commit**
```bash
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-3-compare.md
git commit -m "docs(audit): journey 3 notes — building a comparison"
```
---
### Task 5: Journey 4 — Rankings → shortlist (12% of views)
**Files:**
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-4-rankings.md`
**Interfaces:**
- Consumes: `axe-snippet.js`, `TEMPLATE.md`.
- Produces: `journey-4-rankings.md` for Task 8.
- [ ] **Step 1: Mobile walk-through (390×844)**
1. Navigate to `https://schoolcompare.co.uk/rankings`. Screenshot default state `j4-rankings-mobile.png`.
2. Is the default ranking explained (which metric, which year, what the numbers mean)? Would a parent understand what "top" means here?
3. Filter to a local authority (e.g. Solihull). Count taps; is the filter discoverable on mobile? Screenshot filtered state `j4-rankings-filtered-mobile.png`.
4. Change the ranking metric. Is the metric picker comprehensible (plain-language labels vs. jargon)?
5. Tap through to a school from the list; navigate back — is filter state preserved? (Back-navigation state loss is a classic mobile task-killer.)
6. Run axe snippet on default and filtered states. Record results.
- [ ] **Step 2: Desktop pass (1440×900)**
Repeat at desktop, screenshot `j4-rankings-desktop.png`. Check table semantics (real `<table>` with headers vs. divs — screen-reader implications), sortability affordances, keyboard operation of filters.
- [ ] **Step 3: Write notes file + commit**
Fill `journey-4-rankings.md`.
```bash
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-4-rankings.md
git commit -m "docs(audit): journey 4 notes — rankings"
```
---
### Task 6: Journey 5 — Admissions content (5% of views, light pass)
**Files:**
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-5-admissions.md`
**Interfaces:**
- Consumes: `axe-snippet.js`, `TEMPLATE.md`.
- Produces: `journey-5-admissions.md` for Task 8.
- [ ] **Step 1: Single mobile pass (390×844)**
1. Navigate to `https://schoolcompare.co.uk/admissions`. Screenshot `j5-admissions-mobile.png`.
2. Light checks only: readability (line length, heading hierarchy), whether the recently added SchoolCompare tool cross-links are present and useful, whether the page's look matches the rest of the site (this feeds the cohesion pass), and one axe scan.
3. Check discoverability in reverse: from the home page, how does a parent find this content at all? (5% views may be a discoverability problem rather than a demand problem — note evidence either way.)
- [ ] **Step 2: Write notes file + commit**
Fill `journey-5-admissions.md` (shorter than the others is expected).
```bash
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/journey-5-admissions.md
git commit -m "docs(audit): journey 5 notes — admissions"
```
---
### Task 7: Cross-cutting cohesion pass
**Files:**
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-notes/cohesion-pass.md`
**Interfaces:**
- Consumes: all journey screenshots (scratchpad) and notes files; live site; optionally `nextjs-app` source for token verification.
- Produces: `cohesion-pass.md` for Task 8.
- [ ] **Step 1: Component comparison across pages**
Using the screenshots already captured plus targeted re-visits, compare across `/`, `/compare`, `/rankings`, `/admissions`, and a school page:
1. **Typography:** collect computed styles via `browser_evaluate` on each page — e.g. `[...document.querySelectorAll('h1,h2,h3,body p, button, a')].slice(0,40).map(e => ({tag: e.tagName, size: getComputedStyle(e).fontSize, weight: getComputedStyle(e).fontWeight, family: getComputedStyle(e).fontFamily.split(',')[0]}))` — and diff the scales page-to-page. Record any page using off-scale sizes.
2. **Colour:** same technique for `color`, `backgroundColor` on buttons/links/chips; flag near-duplicate colours (e.g. two blues doing the same job) and any accent colour used inconsistently.
3. **Components:** buttons, chips, cards, empty states, loading states — screenshot side-by-side candidates and note variant drift (different radii, padding, casing, icon usage for the same semantic role).
4. **Navigation & page furniture:** header/footer consistency, page-title patterns, back-link behaviour, breadcrumbs presence/absence across page types.
5. **Recent additions check (from spec):** map-blended hero, characteristic chips, admissions cross-links — do they feel native to the rest of the site?
- [ ] **Step 2: Verify against source where ambiguous**
Where a visual inconsistency could be intentional, check `nextjs-app` styles (grep for the relevant component/tokens) to determine whether a design token exists and is being bypassed, or no token exists. Record which — it changes the recommendation (enforce token vs. create token).
- [ ] **Step 3: Write notes file + commit**
Fill `cohesion-pass.md` with the same evidence → criterion → argument structure (criterion here is typically "consistency and standards" heuristic).
```bash
git add docs/superpowers/specs/2026-07-02-ux-audit-notes/cohesion-pass.md
git commit -m "docs(audit): cross-cutting cohesion pass notes"
```
---
### Task 8: Synthesis — prioritized audit report
**Files:**
- Create: `docs/superpowers/specs/2026-07-02-ux-audit-report.md`
- Read: all files in `docs/superpowers/specs/2026-07-02-ux-audit-notes/`, spec `docs/superpowers/specs/2026-07-02-ux-audit-design.md`
**Interfaces:**
- Consumes: journey notes 15, cohesion notes.
- Produces: the final deliverable report.
- [ ] **Step 1: Consolidate and deduplicate findings**
Read all six notes files. Merge duplicate findings (same root cause observed in several journeys becomes one finding listing all occurrences). Discard any finding lacking evidence or a cited criterion — the spec forbids taste-only findings.
- [ ] **Step 2: Assign final priorities and uplift indications**
For each finding assign P0P3 per the spec's definitions (traffic-weighted impact × severity), and an uplift line: **metric** (one of: home 46% exit rate; share of sessions reaching a school page; compare 32% exit rate; rankings→school click-through; school-page bounce-back-to-Google; accessibility compliance), **direction**, **band** (small/moderate/large) **with one-sentence reasoning**. Sanity rules: a P0/P1 must sit on `/`, `/compare`, `/rankings`, or the school-page template; admissions-only findings cap at P2 unless a WCAG failure.
- [ ] **Step 3: Write the report**
Structure (from spec):
```markdown
# SchoolCompare UX/UI Audit — 2026-07-02
## Method summary
<viewports, journeys, tools, analytics baseline — half a page>
## What works today — keep
<explicit list with why; protects against change-for-change's-sake>
## Findings
### P0 — Urgent
<each: title, evidence (screenshots/axe ids), criterion, argument, recommendation, uplift>
### P1 — High
### P2 — Medium
### P3 — Nice-to-have
## Accessibility summary
<axe violation table by page/viewport + manual check results; overall WCAG 2.2 AA posture>
## Suggested implementation sequence
<grouped batches of related fixes, ordered by uplift-per-effort; each batch sized as a plausible follow-up project>
```
- [ ] **Step 4: Self-check the report against the spec**
Verify: every finding has all four elements (evidence/argument/recommendation/uplift); "works well" section is non-empty; uplift bands never state invented percentages; P0/P1 findings all sit on high-traffic paths; report answers the spec's three goals (a) speed-to-information, (b) end-to-end cohesion, (c) standards compliance. Fix inline.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/specs/2026-07-02-ux-audit-report.md
git commit -m "docs(audit): prioritized UX/UI audit report"
```
@@ -0,0 +1,560 @@
# GIAS OfficialSixthForm Flag Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ingest GIAS's authoritative `OfficialSixthForm` flag into `marts.dim_school.has_sixth_form` and replace every `age_range contains "18"` heuristic in the backend and frontend with it.
**Architecture:** Data flows tap → raw → dbt staging → dbt mart → backend SQL → API payload → Next.js components. The GIAS Singer tap must declare the new CSV column (target-postgres only persists declared columns); the dbt staging model renames it; `dim_school` derives a boolean (with a statutory-age fallback for blank GIAS values); the backend exposes it on list + detail payloads and uses it for the `has_sixth_form=yes|no` filter; the frontend badge/note/filter-labels switch from the age-range substring check to the flag.
**Tech Stack:** Singer SDK (tap), dbt (Postgres), FastAPI + pandas, Next.js + TypeScript, pytest, Jest/RTL.
**Spec:** `docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md` §3.
## Global Constraints
- A school **has a sixth form** iff GIAS `OfficialSixthForm (name)` = `"Has a sixth form"`. `"Does not have a sixth form"` and `"Not applicable"` → false. Blank/NULL (rare) → fall back to `statutory_high_age >= 18`.
- The public API filter parameter stays `has_sixth_form=yes|no` (unchanged contract).
- Filter dropdown labels must drop the age-range parentheticals: "With sixth form" / "Without sixth form" (sixth form ≠ age range).
- Never push to `main`; work stays on branch `feat/gias-sixth-form-flag` (create from `docs/exam-phase-taxonomy` so the spec is included, or from `main` if that branch has merged).
- The dbt models cannot be run locally (no pipeline DB); dbt changes are verified by review + `python -c` schema asserts + existing CI. Do NOT attempt to start a local server.
- The backend marts tables are dbt `table` materializations — rebuilt on every pipeline run, so **no ALTER TABLE migration is needed** for `marts.dim_school`.
- Deployment ordering: the tap must run before dbt on the first pipeline run after deploy (this is already the DAG order: extract → transform). Until that run happens, `has_sixth_form` is absent from the DB; the backend must treat a missing column as "flag false / fallback", never crash.
---
### Task 1: Ingest `OfficialSixthForm (name)` — tap schema + dbt staging
**Files:**
- Modify: `pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py:31-66` (Singer schema)
- Modify: `pipeline/transform/models/staging/stg_gias_establishments.sql` (add renamed column)
**Interfaces:**
- Produces: raw column `"OfficialSixthForm (name)"` in `raw.gias_establishments`; staging column `official_sixth_form` (text: `Has a sixth form` / `Does not have a sixth form` / `Not applicable` / NULL) consumed by Task 2.
- [ ] **Step 1: Add the property to the Singer schema**
In `tap.py`, inside `GIASEstablishmentsStream.schema = th.PropertiesList(...)`, add after the `th.Property("PhaseOfEducation (name)", th.StringType),` line:
```python
th.Property("OfficialSixthForm (name)", th.StringType),
```
- [ ] **Step 2: Verify the tap module still imports and declares the column**
Run:
```bash
cd /Users/tudor/projects/school_compare/pipeline/plugins/extractors/tap-uk-gias && \
python3 -c "
import ast, sys
src = open('tap_uk_gias/tap.py').read()
ast.parse(src)
assert '\"OfficialSixthForm (name)\"' in src.replace(\"'\", '\"')
print('OK: tap declares OfficialSixthForm (name)')
"
```
Expected: `OK: tap declares OfficialSixthForm (name)`
(Uses `ast.parse` instead of importing because `singer_sdk` is not installed locally.)
- [ ] **Step 3: Add the column to the staging model**
In `stg_gias_establishments.sql`, in the `renamed` CTE, add after the `"PhaseOfEducation (name)" as phase,` line:
```sql
nullif(trim("OfficialSixthForm (name)"), '') as official_sixth_form,
```
- [ ] **Step 4: Sanity-check the SQL edit**
Run:
```bash
grep -n "official_sixth_form" /Users/tudor/projects/school_compare/pipeline/transform/models/staging/stg_gias_establishments.sql
```
Expected: one line showing the new column inside the `renamed` CTE (before `from source`).
- [ ] **Step 5: Commit**
```bash
git add pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py pipeline/transform/models/staging/stg_gias_establishments.sql
git commit -m "feat(pipeline): ingest GIAS OfficialSixthForm into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: Derive `dim_school.has_sixth_form` (dbt mart + schema tests + SQLAlchemy model)
**Files:**
- Modify: `pipeline/transform/models/marts/dim_school.sql` (add derived column)
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (document + test the column)
- Modify: `backend/models.py:13-38` (`DimSchool` — add column)
**Interfaces:**
- Consumes: `official_sixth_form` text column from Task 1's staging model.
- Produces: `marts.dim_school.has_sixth_form boolean not null`, and `DimSchool.has_sixth_form = Column(Boolean)` for the backend. Task 3 selects it as `s.has_sixth_form`.
- [ ] **Step 1: Add the derived column to `dim_school.sql`**
In the `select`, add after the `s.age_range` line (`s.statutory_low_age || '-' || s.statutory_high_age as age_range,`):
```sql
-- Authoritative sixth-form flag (spec §3): GIAS OfficialSixthForm.
-- "Not applicable" (nurseries, primaries, PRUs) => false. Blank GIAS
-- value (rare, new establishments) falls back to the statutory age range.
case
when s.official_sixth_form = 'Has a sixth form' then true
when s.official_sixth_form in ('Does not have a sixth form', 'Not applicable') then false
else coalesce(s.statutory_high_age >= 18, false)
end as has_sixth_form,
```
- [ ] **Step 2: Add schema documentation + tests in `_marts_schema.yml`**
Under `- name: dim_school``columns:`, add after the `phase` column block:
```yaml
- name: has_sixth_form
description: >
Authoritative sixth-form flag from GIAS OfficialSixthForm.
"Has a sixth form" => true; "Does not have a sixth form" and
"Not applicable" => false; blank GIAS value falls back to
statutory_high_age >= 18. Replaces the age_range-contains-"18"
heuristic (spec 2026-07-07 §3).
tests:
- not_null
- accepted_values:
values: [true, false]
```
- [ ] **Step 3: Add the column to the `DimSchool` SQLAlchemy model**
In `backend/models.py`, in `class DimSchool`, add after `age_range = Column(String(20))`:
```python
has_sixth_form = Column(Boolean)
```
- [ ] **Step 4: Verify SQL/YAML/Python all parse**
Run:
```bash
cd /Users/tudor/projects/school_compare && \
python3 -c "
import yaml
y = yaml.safe_load(open('pipeline/transform/models/marts/_marts_schema.yml'))
dim = [m for m in y['models'] if m['name'] == 'dim_school'][0]
cols = [c['name'] for c in dim['columns']]
assert 'has_sixth_form' in cols, cols
print('OK: schema yml documents has_sixth_form')
" && \
grep -c "has_sixth_form" pipeline/transform/models/marts/dim_school.sql && \
python3 -c "import ast; ast.parse(open('backend/models.py').read()); print('OK: models.py parses')"
```
Expected: `OK: schema yml documents has_sixth_form`, grep count `>= 1`, `OK: models.py parses`.
- [ ] **Step 5: Commit**
```bash
git add pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/_marts_schema.yml backend/models.py
git commit -m "feat(pipeline): derive dim_school.has_sixth_form from GIAS flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: Backend — expose `has_sixth_form` and replace the filter heuristic
**Files:**
- Modify: `backend/data_loader.py:117-215` (`_MAIN_QUERY` — select the column)
- Modify: `backend/schemas.py:536-553` (`SCHOOL_COLUMNS` — include in list payloads)
- Modify: `backend/app.py:419-422` (filter) and `backend/app.py:589-610` (detail `school_info`)
- Test: `backend/tests/test_sixth_form_flag.py` (new)
**Interfaces:**
- Consumes: `marts.dim_school.has_sixth_form` (Task 2).
- Produces: `has_sixth_form: bool | null` field on `GET /api/schools` items and on `GET /api/schools/{urn}``school_info`. Filter `GET /api/schools?has_sixth_form=yes|no` now driven by the flag. Frontend (Task 4) reads `school.has_sixth_form`.
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_sixth_form_flag.py`:
```python
"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).
The filter and payloads must use dim_school.has_sixth_form, not the old
age_range-contains-"18" substring heuristic. The key regression case is a
16-19 sixth-form college: flag true, but "16-19" contains no "18".
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
"""Latest-year snapshot rows as produced by load_latest_school_data."""
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"admissions_policy": None,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 1000,
"rwm_expected_pct": np.nan,
"attainment_8_score": 50.0,
}
return pd.DataFrame(
[
# 11-18 school WITH a registered sixth form
{**base, "urn": 100001, "school_name": "Alpha High",
"age_range": "11-18", "has_sixth_form": True},
# 16-19 college: old heuristic said NO ("16-19" has no "18"),
# GIAS flag says YES — must appear in the yes-filter results
{**base, "urn": 100002, "school_name": "Beta Sixth Form College",
"age_range": "16-19", "has_sixth_form": True},
# 11-18 age range on paper but NO registered sixth form:
# old heuristic said YES, GIAS flag says NO
{**base, "urn": 100003, "school_name": "Gamma Academy",
"age_range": "11-18", "has_sixth_form": False},
# Missing flag (pipeline not yet re-run) — must not crash,
# must not match the yes-filter
{**base, "urn": 100004, "school_name": "Delta School",
"age_range": "11-16", "has_sixth_form": None},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def _urns(resp):
return sorted(s["urn"] for s in resp.json()["schools"])
def test_filter_yes_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=yes")
assert resp.status_code == 200, resp.text
# 16-19 college included; 11-18-without-sixth-form excluded
assert _urns(resp) == [100001, 100002]
def test_filter_no_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=no")
assert resp.status_code == 200, resp.text
# Gamma (flag false) and Delta (flag missing => not true)
assert _urns(resp) == [100003, 100004]
def test_list_payload_includes_flag(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[100002]["has_sixth_form"] is True
assert by_urn[100003]["has_sixth_form"] is False
assert by_urn[100004]["has_sixth_form"] is None
def test_detail_payload_includes_flag(client):
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests/test_sixth_form_flag.py -v`
Expected: FAIL — `test_filter_yes_uses_flag_not_age_range` asserts `[100001, 100002]` but the age-range heuristic returns `[100001, 100003]`; the payload tests fail with `KeyError: 'has_sixth_form'`.
- [ ] **Step 3: Select the column in `_MAIN_QUERY`**
In `backend/data_loader.py`, in `_MAIN_QUERY`, add after `s.age_range,`:
```sql
s.has_sixth_form,
```
- [ ] **Step 4: Include it in list payloads**
In `backend/schemas.py`, in `SCHOOL_COLUMNS`, add after `"age_range",`:
```python
"has_sixth_form",
```
(`app.py` builds list responses from `SCHOOL_COLUMNS ∩ df.columns`, so a DB that predates the pipeline re-run simply omits the field — no crash.)
- [ ] **Step 5: Replace the filter heuristic in `app.py`**
Replace lines 419-422:
```python
if has_sixth_form == "yes":
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
elif has_sixth_form == "no":
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
```
with:
```python
# GIAS OfficialSixthForm flag (dim_school.has_sixth_form). NULL (flag not
# yet populated by the pipeline) is treated as "no sixth form".
if has_sixth_form in ("yes", "no"):
if "has_sixth_form" in df_latest.columns:
flag = df_latest["has_sixth_form"].eq(True)
else: # DB predates the pipeline re-run — fall back to age range
flag = df_latest["age_range"].str.contains("18", na=False)
df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]
```
- [ ] **Step 6: Add the flag to the detail payload**
In `backend/app.py` `school_info` dict (line ~598), add after `"age_range": latest.get("age_range", ""),`:
```python
"has_sixth_form": latest.get("has_sixth_form"),
```
(`convert_to_native` already maps NaN/None → null and numpy bools → bool.)
- [ ] **Step 7: Run the new tests**
Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests/test_sixth_form_flag.py -v`
Expected: 4 passed.
- [ ] **Step 8: Run the full backend suite**
Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests -v`
Expected: all pass (the pre-existing `test_school_details.py` df has no `has_sixth_form` column — `latest.get()` returns None, serialized as null).
- [ ] **Step 9: Commit**
```bash
git add backend/data_loader.py backend/schemas.py backend/app.py backend/tests/test_sixth_form_flag.py
git commit -m "feat(api): drive has_sixth_form filter and payloads from GIAS flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Frontend — badge, note, row tag, and filter labels use the flag
**Files:**
- Modify: `nextjs-app/lib/types.ts:10-30` (`School` interface)
- Modify: `nextjs-app/components/SecondarySchoolDetailView.tsx:101` (badge + coming-soon note)
- Modify: `nextjs-app/components/SecondarySchoolRow.tsx:25-27` (row tag)
- Modify: `nextjs-app/components/FilterBar.tsx:370-372` (labels only — param name unchanged)
- Test: `nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx` (new)
**Interfaces:**
- Consumes: `has_sixth_form: boolean | null` on both list items and `school_info` (Task 3; both are typed as `School`).
- Produces: no new exports — behavior change only.
- [ ] **Step 1: Write the failing test**
Create `nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx`:
```tsx
/**
* SecondarySchoolRow — sixth-form tag must come from the GIAS
* has_sixth_form flag, not the age_range-contains-"18" heuristic.
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { SecondarySchoolRow } from '@/components/SecondarySchoolRow';
import type { School } from '@/lib/types';
const base = {
urn: 100002,
school_name: 'Beta Sixth Form College',
local_authority: 'Testshire',
school_type: 'Academy',
phase: 'Secondary',
gender: 'Mixed',
attainment_8_score: 50.0,
} as unknown as School;
describe('SecondarySchoolRow sixth-form tag', () => {
it('shows the tag for a 16-19 college with the GIAS flag set', () => {
render(
<SecondarySchoolRow
school={{ ...base, age_range: '16-19', has_sixth_form: true }}
/>,
);
expect(screen.getByText('Sixth form')).toBeInTheDocument();
});
it('hides the tag for an 11-18 school without a registered sixth form', () => {
render(
<SecondarySchoolRow
school={{ ...base, age_range: '11-18', has_sixth_form: false }}
/>,
);
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
});
it('hides the tag when the flag is missing (pipeline not yet re-run)', () => {
render(
<SecondarySchoolRow school={{ ...base, age_range: '11-18' }} />,
);
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx jest __tests__/components/SecondarySchoolRow.test.tsx`
Expected: FAIL — first test can't find "Sixth form" ("16-19" fails the substring check), second test finds an unexpected "Sixth form" tag. (If TS complains that `has_sixth_form` is not on `School`, that is the same failure — proceed.)
- [ ] **Step 3: Add the field to the `School` type**
In `nextjs-app/lib/types.ts`, in `export interface School`, add after `age_range: string | null;`:
```ts
has_sixth_form?: boolean | null;
```
- [ ] **Step 4: Switch `SecondarySchoolRow` to the flag**
Replace the helper at `SecondarySchoolRow.tsx:25-27`:
```ts
function hasSixthForm(school: School): boolean {
return school.age_range?.includes('18') ?? false;
}
```
with:
```ts
function hasSixthForm(school: School): boolean {
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
return school.has_sixth_form ?? false;
}
```
- [ ] **Step 5: Switch `SecondarySchoolDetailView` to the flag**
Replace line 101:
```ts
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
```
with:
```ts
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
```
(This drives both the header "Sixth form" badge at line ~230 and the "Post-16 destination data coming soon" note at line ~715 — no changes needed there.)
- [ ] **Step 6: Fix the filter labels in `FilterBar.tsx`**
Replace:
```tsx
<option value="yes">With sixth form (11-18)</option>
<option value="no">Without sixth form (11-16)</option>
```
with:
```tsx
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
```
- [ ] **Step 7: Run the new test and verify it passes**
Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx jest __tests__/components/SecondarySchoolRow.test.tsx`
Expected: 3 passed.
- [ ] **Step 8: Run the full frontend checks**
Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx tsc --noEmit && npx jest`
Expected: typecheck clean, all Jest suites pass.
- [ ] **Step 9: Verify no heuristic remains**
Run:
```bash
grep -rn "includes('18')\|contains(\"18\")" /Users/tudor/projects/school_compare/nextjs-app/components /Users/tudor/projects/school_compare/backend --include="*.tsx" --include="*.ts" --include="*.py" | grep -v test
```
Expected: only the documented fallback inside `app.py` (DB-predates-pipeline branch); no other hits.
- [ ] **Step 10: Commit**
```bash
git add nextjs-app/lib/types.ts nextjs-app/components/SecondarySchoolRow.tsx nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/components/FilterBar.tsx nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx
git commit -m "feat(ui): sixth-form badge, note and filter labels use GIAS flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 5: Update the spec status + PR
**Files:**
- Modify: `docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md` (§3 "Pipeline change (future work)" → implemented)
**Interfaces:**
- Consumes: everything above merged into the branch.
- Produces: PR ready for review; e2e journeys are the promotion gate (no journey currently exercises the sixth-form filter, and the API contract is unchanged, so no e2e change is required — state this in the PR body).
- [ ] **Step 1: Mark spec §3 pipeline change as implemented**
In the spec, change the §3 heading `### Pipeline change (future work)` to `### Pipeline change (implemented 2026-07-07)` and append one line at the end of that subsection:
```markdown
Implemented in `feat/gias-sixth-form-flag` — see
`docs/superpowers/plans/2026-07-07-gias-sixth-form-flag.md`.
```
- [ ] **Step 2: Commit**
```bash
git add docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md
git commit -m "docs: mark sixth-form flag pipeline change implemented
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
- [ ] **Step 3: Push and open the PR (Gitea)**
Push the branch, then create the PR against `main` using the Gitea API via the git credential helper (token-header auth 401s on this Gitea; basic auth from `git credential fill` works):
```bash
git push -u origin feat/gias-sixth-form-flag
```
PR title: `feat: drive sixth-form separation from GIAS OfficialSixthForm flag`
PR body must note: (1) API contract unchanged (`has_sixth_form=yes|no`), (2) flag is NULL until the next pipeline run — backend and frontend degrade to "no sixth form" / age-range fallback, (3) no e2e journey change needed, and end with the standard generation footer.
- [ ] **Step 4: Verify CI passes**
Watch the PR checks (typecheck, tests, builds, AI review). All must pass before merge; merging deploys to staging automatically.
@@ -0,0 +1,799 @@
# GIAS Code Dictionaries Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Store the six GIAS classification fields as official DfE integer codes in the marts and translate code → name in application code, leaving the API contract (name strings) unchanged.
**Architecture:** A generation script downloads the public GIAS bulk CSV and emits the dictionaries (Python dicts + a dbt seed) from real data. The tap ingests the `(code)` columns, staging casts them, `dim_school`/`dim_location` keep only codes, and translation happens in exactly two places: `backend/data_loader.py` right after `pd.read_sql`, and `pipeline/scripts/sync_typesense.py` before indexing. A dbt seed test warns when DfE adds/renames a value; a parity test keeps the backend and pipeline dictionary copies identical.
**Tech Stack:** Singer SDK tap, dbt (Postgres), FastAPI + pandas, Typesense sync script, pytest.
**Spec:** `docs/superpowers/specs/2026-07-09-gias-code-dictionaries-design.md`
## Global Constraints
- **Numeric code values are never assumed.** Every literal code used in SQL or yml (status filter, sixth-form derivation, phase cascade) must be verified against `pipeline/transform/seeds/gias_code_names.csv` generated in Task 1 from the live CSV. The literals written in this plan are best-current-knowledge and each carries a verification step.
- **Names served by the API must stay byte-identical** to today's strings (e.g. `Does not apply`, `Open, but proposed to close`) — UI heuristics compare exact strings.
- The `(name)` columns stay declared in the tap and present in raw; staging stops exposing them.
- `dim_school` and `dim_location` status filters must stay identical (API inner-joins them).
- Backend tests run via: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -v` (no local pytest exists).
- dbt cannot run locally — dbt changes are verified statically (grep / yaml parse) + CI.
- Never push to `main`. Work on branch `feat/gias-code-dictionaries` (branch off `docs/gias-code-dictionaries` so the spec is included, or off `main` if that has merged).
- Commits end with: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
- Deploy runbook (accepted window, spec §7): merge → deploy → trigger `school_data_daily` immediately. No code-level fallback for the old-schema window.
---
### Task 1: Dictionary generation script, canonical module, pipeline copy, seed
**Files:**
- Create: `pipeline/scripts/generate_gias_codes.py`
- Create: `backend/gias_codes.py` (content generated by the script)
- Create: `pipeline/scripts/gias_codes.py` (byte-identical copy)
- Create: `pipeline/transform/seeds/gias_code_names.csv` (generated)
- Test: `backend/tests/test_gias_codes.py`
**Interfaces:**
- Produces: `backend/gias_codes.py` exporting `SCHOOL_TYPE`, `ESTABLISHMENT_STATUS`, `PHASE_OF_EDUCATION`, `OFFICIAL_SIXTH_FORM`, `RELIGIOUS_CHARACTER`, `ADMISSIONS_POLICY` (each `dict[int, str]`) and `translate(code, mapping) -> str | None`. Task 4 imports these; Task 5 imports the pipeline copy; Task 3 reads code literals from the seed CSV.
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_gias_codes.py`:
```python
"""Tests for the GIAS code->name dictionaries (spec 2026-07-09).
The dictionaries are generated from the live GIAS bulk CSV by
pipeline/scripts/generate_gias_codes.py — these tests assert the module's
contract, key sentinel values the marts/UI depend on, and that the pipeline
copy has not drifted from the canonical backend module.
"""
import math
from pathlib import Path
from backend.gias_codes import (
ADMISSIONS_POLICY,
ESTABLISHMENT_STATUS,
OFFICIAL_SIXTH_FORM,
PHASE_OF_EDUCATION,
RELIGIOUS_CHARACTER,
SCHOOL_TYPE,
translate,
)
REPO = Path(__file__).resolve().parents[2]
def test_translate_known_code():
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
assert translate(open_code, ESTABLISHMENT_STATUS) == "Open"
def test_translate_unknown_code_degrades_gracefully():
assert translate(9999, ESTABLISHMENT_STATUS) == "Unknown (9999)"
def test_translate_none_and_nan_return_none():
assert translate(None, ESTABLISHMENT_STATUS) is None
assert translate(float("nan"), ESTABLISHMENT_STATUS) is None
def test_translate_accepts_float_codes():
# pd.read_sql yields float columns when NULLs are present
open_code = next(c for c, n in ESTABLISHMENT_STATUS.items() if n == "Open")
assert translate(float(open_code), ESTABLISHMENT_STATUS) == "Open"
def test_sentinel_names_present():
"""Names the marts/UI compare against must exist verbatim."""
assert "Open" in ESTABLISHMENT_STATUS.values()
assert "Open, but proposed to close" in ESTABLISHMENT_STATUS.values()
assert "Has a sixth form" in OFFICIAL_SIXTH_FORM.values()
assert "Primary" in PHASE_OF_EDUCATION.values()
assert "Secondary" in PHASE_OF_EDUCATION.values()
assert "Does not apply" in RELIGIOUS_CHARACTER.values()
assert all(len(d) > 0 for d in (
SCHOOL_TYPE, ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION,
OFFICIAL_SIXTH_FORM, RELIGIOUS_CHARACTER, ADMISSIONS_POLICY,
))
def test_pipeline_copy_is_identical():
canonical = (REPO / "backend" / "gias_codes.py").read_text()
copy = (REPO / "pipeline" / "scripts" / "gias_codes.py").read_text()
assert canonical == copy, (
"pipeline/scripts/gias_codes.py has drifted from backend/gias_codes.py — "
"regenerate with pipeline/scripts/generate_gias_codes.py and copy the file"
)
def test_seed_matches_dictionaries():
import csv
fields = {
"school_type": SCHOOL_TYPE,
"establishment_status": ESTABLISHMENT_STATUS,
"phase_of_education": PHASE_OF_EDUCATION,
"official_sixth_form": OFFICIAL_SIXTH_FORM,
"religious_character": RELIGIOUS_CHARACTER,
"admissions_policy": ADMISSIONS_POLICY,
}
seed_path = REPO / "pipeline" / "transform" / "seeds" / "gias_code_names.csv"
seed: dict[str, dict[int, str]] = {k: {} for k in fields}
with open(seed_path, newline="") as fh:
for row in csv.DictReader(fh):
seed[row["field"]][int(row["code"])] = row["name"]
assert seed == fields
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_gias_codes.py -v`
Expected: FAIL at import — `ModuleNotFoundError: No module named 'backend.gias_codes'`.
- [ ] **Step 3: Write the generation script**
Create `pipeline/scripts/generate_gias_codes.py`:
```python
"""Generate GIAS code->name dictionaries from the live bulk CSV.
Writes:
- backend/gias_codes.py (canonical Python module)
- pipeline/scripts/gias_codes.py (byte-identical copy)
- pipeline/transform/seeds/gias_code_names.csv (dbt seed for drift test)
Run from the repo root whenever the dbt drift test warns that DfE
added/renamed a value: python pipeline/scripts/generate_gias_codes.py
"""
from __future__ import annotations
import io
import sys
from datetime import date, timedelta
from pathlib import Path
import pandas as pd
import requests
GIAS_URL = (
"https://ea-edubase-api-prod.azurewebsites.net"
"/edubase/downloads/public/edubasealldata{date}.csv"
)
# (CSV code column, CSV name column, python dict name, seed field key)
FIELDS = [
("TypeOfEstablishment (code)", "TypeOfEstablishment (name)", "SCHOOL_TYPE", "school_type"),
("EstablishmentStatus (code)", "EstablishmentStatus (name)", "ESTABLISHMENT_STATUS", "establishment_status"),
("PhaseOfEducation (code)", "PhaseOfEducation (name)", "PHASE_OF_EDUCATION", "phase_of_education"),
("OfficialSixthForm (code)", "OfficialSixthForm (name)", "OFFICIAL_SIXTH_FORM", "official_sixth_form"),
("ReligiousCharacter (code)", "ReligiousCharacter (name)", "RELIGIOUS_CHARACTER", "religious_character"),
("AdmissionsPolicy (code)", "AdmissionsPolicy (name)", "ADMISSIONS_POLICY", "admissions_policy"),
]
MODULE_HEADER = '''"""GIAS code -> name dictionaries.
GENERATED by pipeline/scripts/generate_gias_codes.py from the GIAS bulk CSV
— do not edit by hand; rerun the script when the dbt drift test warns.
The canonical file is backend/gias_codes.py; pipeline/scripts/gias_codes.py
must be byte-identical (enforced by backend/tests/test_gias_codes.py).
"""
from __future__ import annotations
import logging
import math
logger = logging.getLogger(__name__)
'''
MODULE_FOOTER = '''
def translate(code, mapping: dict[int, str]) -> str | None:
"""Translate a GIAS code to its display name.
None/NaN -> None (column absent or suppressed). Unknown codes degrade to
"Unknown (<code>)" with a warning so a new DfE value never blanks the UI.
"""
if code is None or (isinstance(code, float) and math.isnan(code)):
return None
code = int(code)
if code not in mapping:
logger.warning("Unknown GIAS code %s (not in dictionary)", code)
return f"Unknown ({code})"
return mapping[code]
'''
def download_csv() -> pd.DataFrame:
for day in (date.today(), date.today() - timedelta(days=1)):
url = GIAS_URL.format(date=day.strftime("%Y%m%d"))
print(f"Downloading {url}")
resp = requests.get(url, timeout=300)
if resp.status_code == 404:
continue
resp.raise_for_status()
return pd.read_csv(
io.StringIO(resp.content.decode("latin-1")),
dtype=str, keep_default_na=False,
)
sys.exit("GIAS CSV not available for today or yesterday")
def main() -> None:
repo = Path(__file__).resolve().parents[2]
df = download_csv()
module_parts = [MODULE_HEADER]
seed_rows: list[tuple[str, int, str]] = []
for code_col, name_col, dict_name, field_key in FIELDS:
pairs = (
df[[code_col, name_col]]
.loc[lambda d: (d[code_col] != "") & (d[name_col] != "")]
.drop_duplicates()
)
mapping = sorted((int(c), n) for c, n in pairs.itertuples(index=False))
dupes = len(mapping) - len({c for c, _ in mapping})
if dupes:
sys.exit(f"{code_col}: {dupes} codes map to multiple names — investigate before generating")
lines = [f"{dict_name}: dict[int, str] = {{"]
for code, name in mapping:
escaped = name.replace('"', '\\"')
lines.append(f' {code}: "{escaped}",')
lines.append("}\n")
module_parts.append("\n".join(lines))
seed_rows += [(field_key, code, name) for code, name in mapping]
module = "\n".join(module_parts) + MODULE_FOOTER
(repo / "backend" / "gias_codes.py").write_text(module)
(repo / "pipeline" / "scripts" / "gias_codes.py").write_text(module)
seed_path = repo / "pipeline" / "transform" / "seeds" / "gias_code_names.csv"
with open(seed_path, "w", newline="") as fh:
import csv
w = csv.writer(fh)
w.writerow(["field", "code", "name"])
w.writerows(seed_rows)
print(f"Wrote backend/gias_codes.py, pipeline/scripts/gias_codes.py, {seed_path.name}")
print("\nKey codes for the dbt work (Task 3):")
for field in ("establishment_status", "phase_of_education", "official_sixth_form"):
print(f" {field}:")
for f, code, name in seed_rows:
if f == field:
print(f" {code} = {name}")
if __name__ == "__main__":
main()
```
- [ ] **Step 4: Run the generator**
Run: `cd /Users/tudor/projects/school_compare && uv run --with pandas --with requests python pipeline/scripts/generate_gias_codes.py`
Expected: downloads the CSV (~100MB, may take a minute), writes the three files, and prints the status/phase/sixth-form code tables. **Record the printed code tables — Task 3 needs them.** If the download fails twice, report BLOCKED (no network or GIAS outage) rather than inventing dictionary content.
- [ ] **Step 5: Run the tests again**
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_gias_codes.py -v`
Expected: 7 passed. If `test_sentinel_names_present` fails, the GIAS vocabulary differs from expectations — inspect the generated module and report DONE_WITH_CONCERNS naming the differing value; do not edit the generated names.
- [ ] **Step 6: Commit**
```bash
git add pipeline/scripts/generate_gias_codes.py backend/gias_codes.py pipeline/scripts/gias_codes.py pipeline/transform/seeds/gias_code_names.csv backend/tests/test_gias_codes.py
git commit -m "feat: GIAS code->name dictionaries generated from live bulk CSV
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: Tap ingests the (code) columns; staging exposes codes, drops names
**Files:**
- Modify: `pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py` (Singer schema)
- Modify: `pipeline/transform/models/staging/stg_gias_establishments.sql`
**Interfaces:**
- Produces: staging columns `school_type_code`, `status_code`, `phase_code`, `official_sixth_form_code`, `religious_character_code`, `admissions_policy_code` (all int) consumed by Task 3. Staging **stops exposing** `school_type`, `status`, `phase`, `official_sixth_form`, `religious_character`, `admissions_policy` (names stay in raw only).
- [ ] **Step 1: Add the six (code) properties to the Singer schema**
In `tap.py`, `GIASEstablishmentsStream.schema`, add each `(code)` property directly above its existing `(name)` sibling:
```python
th.Property("TypeOfEstablishment (code)", th.StringType),
th.Property("PhaseOfEducation (code)", th.StringType),
th.Property("EstablishmentStatus (code)", th.StringType),
th.Property("Gender (name)", ...) # existing line — for placement reference only
th.Property("ReligiousCharacter (code)", th.StringType),
th.Property("AdmissionsPolicy (code)", th.StringType),
th.Property("OfficialSixthForm (code)", th.StringType),
```
(The exact insertion order doesn't matter — the schema is a dict — but keep each `(code)` adjacent to its `(name)` for readability. Do NOT remove any `(name)` property.)
- [ ] **Step 2: Rewrite the six columns in staging**
In `stg_gias_establishments.sql` `renamed` CTE, replace:
```sql
"TypeOfEstablishment (name)" as school_type,
"PhaseOfEducation (name)" as phase,
nullif(trim("OfficialSixthForm (name)"), '') as official_sixth_form,
"ReligiousCharacter (name)" as religious_character,
"AdmissionsPolicy (name)" as admissions_policy,
"EstablishmentStatus (name)" as status,
```
with:
```sql
cast(nullif(trim("TypeOfEstablishment (code)"), '') as integer) as school_type_code,
cast(nullif(trim("PhaseOfEducation (code)"), '') as integer) as phase_code,
cast(nullif(trim("OfficialSixthForm (code)"), '') as integer) as official_sixth_form_code,
cast(nullif(trim("ReligiousCharacter (code)"), '') as integer) as religious_character_code,
cast(nullif(trim("AdmissionsPolicy (code)"), '') as integer) as admissions_policy_code,
cast(nullif(trim("EstablishmentStatus (code)"), '') as integer) as status_code,
```
(The name lines are scattered through the CTE — replace each in place; the six name aliases must no longer appear in the model.)
- [ ] **Step 3: Verify statically**
Run:
```bash
cd /Users/tudor/projects/school_compare && \
python3 -c "import ast; ast.parse(open('pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py').read()); print('tap OK')" && \
grep -c "(code)" pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py && \
grep -E "as (school_type|status|phase|official_sixth_form|religious_character|admissions_policy)," pipeline/transform/models/staging/stg_gias_establishments.sql; echo "name-alias grep exit=$? (want 1 = none found)"
```
Expected: `tap OK`, code-column count `6`, and the final grep finds nothing (exit 1).
- [ ] **Step 4: Commit**
```bash
git add pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py pipeline/transform/models/staging/stg_gias_establishments.sql
git commit -m "feat(pipeline): ingest GIAS code columns; staging exposes codes not names
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: Marts store codes; dbt tests + drift test
**Files:**
- Modify: `pipeline/transform/models/marts/dim_school.sql`
- Modify: `pipeline/transform/models/marts/dim_location.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml`
- Create: `pipeline/transform/tests/assert_gias_code_names_match_seed.sql`
**Interfaces:**
- Consumes: staging code columns from Task 2; code literals from `pipeline/transform/seeds/gias_code_names.csv` (Task 1).
- Produces: `dim_school` columns `school_type_code`, `status_code`, `phase_code`, `religious_character_code`, `admissions_policy_code` (int) replacing their string columns; `has_sixth_form` unchanged (bool). Task 4's `_MAIN_QUERY` selects these.
**Before writing SQL: open `pipeline/transform/seeds/gias_code_names.csv` and confirm the literals below.** Best-current-knowledge values (VERIFY EACH):
`establishment_status`: 1 = Open, 3 = "Open, but proposed to close" (2 = Closed, 4 = Proposed to open).
`phase_of_education`: 0 = Not applicable, 2 = Primary, 4 = Secondary, 7 = All-through.
`official_sixth_form`: 1 = Has a sixth form, 2 = Does not have a sixth form, 0 = Not applicable.
If any differ, use the seed's values everywhere below and say so in your report.
- [ ] **Step 1: Rewrite dim_school.sql derivations in code space**
Replace the phase cascade block (`case ... end as phase,`) with:
```sql
-- Phase in GIAS code space (see seeds/gias_code_names.csv):
-- 2 = Primary, 4 = Secondary, 7 = All-through, 0 = Not applicable.
case
-- 1. Trust GIAS phase when it's a real value (0 = the catch-all "Not Applicable")
when s.phase_code is not null and s.phase_code != 0
then s.phase_code
-- 2. Infer from statutory age range (independent schools still publish these)
when s.statutory_high_age is not null and s.statutory_high_age <= 11 then 2
when s.statutory_low_age is not null and s.statutory_low_age >= 11 then 4
when s.statutory_low_age is not null and s.statutory_high_age is not null
and s.statutory_low_age < 11 and s.statutory_high_age > 11 then 7
-- 3. Fallback: infer from school name (covers independents with missing ages)
when s.school_name ilike '%primary%'
or s.school_name ilike '%infant%'
or s.school_name ilike '%junior%'
or s.school_name ilike '%preparatory%'
or s.school_name ilike '% prep school%'
or s.school_name ilike '% prep %'
then 2
when s.school_name ilike '%secondary%'
or s.school_name ilike '%high school%'
or s.school_name ilike '%grammar%'
or s.school_name ilike '%senior school%'
or s.school_name ilike '%upper school%'
then 4
-- 4. Give up — null renders no phase pill
else null
end as phase_code,
```
Replace `s.school_type,` with `s.school_type_code,`; `s.religious_character,` with `s.religious_character_code,`; `s.admissions_policy,` with `s.admissions_policy_code,`; `s.status,` with `s.status_code,`.
Replace the has_sixth_form case with:
```sql
-- GIAS OfficialSixthForm in code space: 1 = has, 2 = does not, 0 = N/A.
-- Null (rare, new establishments) falls back to the statutory age range.
case
when s.official_sixth_form_code = 1 then true
when s.official_sixth_form_code in (0, 2) then false
else coalesce(s.statutory_high_age >= 18, false)
end as has_sixth_form,
```
Replace the status filter with:
```sql
-- 1 = Open; 3 = Open, but proposed to close (still operating; drops out when
-- GIAS flips to Closed — marts fully rebuild each run).
where s.status_code in (1, 3)
```
- [ ] **Step 2: Same filter in dim_location.sql**
Replace its `where s.status in ('Open', 'Open, but proposed to close')` (and the comment above it) with:
```sql
-- Must match dim_school's status filter exactly (the API inner-joins the two).
where s.status_code in (1, 3)
```
- [ ] **Step 3: Update _marts_schema.yml**
Under `dim_school` columns: rename `phase``phase_code` (keep the warn-severity not_null, reword description to mention codes); replace the `status` accepted_values block with:
```yaml
- name: status_code
description: GIAS EstablishmentStatus code (1 = Open, 3 = Open but proposed to close)
tests:
- accepted_values:
values: [1, 3]
```
Add warn-severity accepted_values for the other codes, values copied from the seed (school_type/religious/admissions lists are long — paste the full code list from `gias_code_names.csv` for each):
```yaml
- name: school_type_code
tests:
- accepted_values:
severity: warn
values: [<all school_type codes from the seed>]
- name: religious_character_code
tests:
- accepted_values:
severity: warn
values: [<all religious_character codes from the seed>]
- name: admissions_policy_code
tests:
- accepted_values:
severity: warn
values: [<all admissions_policy codes from the seed>]
```
(`<...>` here means: paste the actual comma-separated integers from the seed file — the lists exist by the time this task runs. Leaving a literal `<...>` in the yml is a task failure.)
`has_sixth_form` tests stay unchanged.
- [ ] **Step 4: Write the drift test**
Create `pipeline/transform/tests/assert_gias_code_names_match_seed.sql`:
```sql
-- Warn when the live GIAS CSV carries a (code, name) pair we don't have in
-- the dictionary seed — i.e. DfE added or renamed a value. Fix by rerunning
-- pipeline/scripts/generate_gias_codes.py and committing the regenerated
-- dictionaries + seed together.
{{ config(severity='warn') }}
with raw_pairs as (
{% for field_key, code_col, name_col in [
('school_type', 'TypeOfEstablishment (code)', 'TypeOfEstablishment (name)'),
('establishment_status', 'EstablishmentStatus (code)', 'EstablishmentStatus (name)'),
('phase_of_education', 'PhaseOfEducation (code)', 'PhaseOfEducation (name)'),
('official_sixth_form', 'OfficialSixthForm (code)', 'OfficialSixthForm (name)'),
('religious_character', 'ReligiousCharacter (code)', 'ReligiousCharacter (name)'),
('admissions_policy', 'AdmissionsPolicy (code)', 'AdmissionsPolicy (name)')
] %}
select distinct
'{{ field_key }}' as field,
cast(nullif(trim("{{ code_col }}"), '') as integer) as code,
nullif(trim("{{ name_col }}"), '') as name
from {{ source('raw', 'gias_establishments') }}
where nullif(trim("{{ code_col }}"), '') is not null
and nullif(trim("{{ name_col }}"), '') is not null
{% if not loop.last %}union all{% endif %}
{% endfor %}
)
select r.*
from raw_pairs r
left join {{ ref('gias_code_names') }} s
on s.field = r.field
and s.code = r.code
and s.name = r.name
where s.field is null
```
- [ ] **Step 5: Verify statically**
Run:
```bash
cd /Users/tudor/projects/school_compare && \
uv run --with pyyaml python -c "import yaml; yaml.safe_load(open('pipeline/transform/models/marts/_marts_schema.yml')); print('yml OK')" && \
grep -c "_code" pipeline/transform/models/marts/dim_school.sql && \
grep -n "status_code in (1, 3)" pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/dim_location.sql && \
grep -rn "s\.status\b\|s\.phase\b\|s\.school_type\b\|s\.religious_character\b\|s\.admissions_policy\b\|official_sixth_form\b" pipeline/transform/models/marts/dim_school.sql | grep -v "_code"; echo "stale-name grep exit=$? (want 1)"
```
Expected: `yml OK`, both filters matched, and no stale name-column references (final grep exits 1).
- [ ] **Step 6: Commit**
```bash
git add pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/dim_location.sql pipeline/transform/models/marts/_marts_schema.yml pipeline/transform/tests/assert_gias_code_names_match_seed.sql
git commit -m "feat(pipeline): dim_school/dim_location store GIAS codes; seed drift test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Backend translates at the API boundary
**Files:**
- Modify: `backend/models.py` (DimSchool columns)
- Modify: `backend/data_loader.py` (`_MAIN_QUERY` + translation)
- Test: `backend/tests/test_gias_translation.py` (new)
**Interfaces:**
- Consumes: `backend/gias_codes.py` dictionaries + `translate` (Task 1); mart code columns (Task 3).
- Produces: `translate_gias_code_columns(df) -> df` in `backend/data_loader.py`; after `load_school_data_as_dataframe()` the DataFrame carries today's name columns (`phase`, `school_type`, `status`, `religious_denomination`, `admissions_policy`) — every downstream consumer unchanged.
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_gias_translation.py`:
```python
"""API-boundary translation: marts now carry GIAS codes; the DataFrame the
rest of the backend sees must carry today's name strings."""
import numpy as np
import pandas as pd
from backend.data_loader import translate_gias_code_columns
from backend.gias_codes import ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION
def _code_for(mapping, name):
return next(c for c, n in mapping.items() if n == name)
def test_codes_become_todays_names():
df = pd.DataFrame([{
"urn": 1,
"phase_code": float(_code_for(PHASE_OF_EDUCATION, "Primary")),
"school_type_code": np.nan,
"status_code": float(_code_for(ESTABLISHMENT_STATUS, "Open, but proposed to close")),
"religious_character_code": np.nan,
"admissions_policy_code": np.nan,
}])
out = translate_gias_code_columns(df)
row = out.iloc[0]
assert row["phase"] == "Primary"
assert row["status"] == "Open, but proposed to close"
assert row["school_type"] is None
assert row["religious_denomination"] is None
assert row["admissions_policy"] is None
def test_unknown_code_degrades_not_blanks():
df = pd.DataFrame([{"urn": 1, "phase_code": 9999.0}])
out = translate_gias_code_columns(df)
assert out.iloc[0]["phase"] == "Unknown (9999)"
def test_missing_code_columns_are_a_noop():
"""Old-schema DataFrames (tests, pre-pipeline DBs) pass through untouched."""
df = pd.DataFrame([{"urn": 1, "phase": "Primary", "status": "Open"}])
out = translate_gias_code_columns(df)
assert out.iloc[0]["phase"] == "Primary"
assert out.iloc[0]["status"] == "Open"
```
- [ ] **Step 2: Run to verify failure**
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_gias_translation.py -v`
Expected: FAIL — `ImportError: cannot import name 'translate_gias_code_columns'`.
- [ ] **Step 3: Implement translation in data_loader.py**
Add near the top of `backend/data_loader.py` (after existing imports):
```python
from .gias_codes import (
ADMISSIONS_POLICY,
ESTABLISHMENT_STATUS,
PHASE_OF_EDUCATION,
RELIGIOUS_CHARACTER,
SCHOOL_TYPE,
translate,
)
# mart code column -> (API name column, dictionary)
_GIAS_CODE_COLUMNS = {
"phase_code": ("phase", PHASE_OF_EDUCATION),
"school_type_code": ("school_type", SCHOOL_TYPE),
"status_code": ("status", ESTABLISHMENT_STATUS),
"religious_character_code": ("religious_denomination", RELIGIOUS_CHARACTER),
"admissions_policy_code": ("admissions_policy", ADMISSIONS_POLICY),
}
def translate_gias_code_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Map GIAS code columns to today's name columns (API contract).
Runs immediately after pd.read_sql so every downstream consumer —
filters, PHASE_GROUPS, payloads, /api/filters — keeps seeing names.
DataFrames without the code columns (old schema, test fixtures) pass
through unchanged.
"""
for code_col, (name_col, mapping) in _GIAS_CODE_COLUMNS.items():
if code_col in df.columns:
df[name_col] = df[code_col].map(lambda c: translate(c, mapping))
return df
```
- [ ] **Step 4: Switch `_MAIN_QUERY` to code columns and call the translation**
In `_MAIN_QUERY` replace:
`s.phase,``s.phase_code,` · `s.school_type,``s.school_type_code,` · `s.religious_character AS religious_denomination,``s.religious_character_code,` · `s.admissions_policy,``s.admissions_policy_code,` · `s.status,``s.status_code,`
In `load_school_data_as_dataframe()`, insert the call immediately after the empty-check and **before** the existing `normalize_school_type` line:
```python
if df.empty:
return df
df = translate_gias_code_columns(df)
# Build address string
...
# Normalize school type (existing line — now normalises the translated name)
df["school_type"] = df["school_type"].apply(normalize_school_type)
```
- [ ] **Step 5: Update DimSchool in models.py**
Replace `phase = Column(String(100))`, `school_type = Column(String(100))`, `religious_character = Column(String(100))`, `admissions_policy = Column(String(50))`, `status = Column(String(50))` with:
```python
phase_code = Column(Integer)
school_type_code = Column(Integer)
religious_character_code = Column(Integer)
admissions_policy_code = Column(Integer)
status_code = Column(Integer)
```
Then check nothing else references the removed attributes:
```bash
grep -rn "\.phase\b\|\.school_type\b\|\.religious_character\b\|\.admissions_policy\b\|\.status\b" backend/*.py | grep -i "dimschool\|DimSchool"
```
Expected: no hits (the backend reads via `_MAIN_QUERY`, not ORM attributes). If there are hits, update them to the `_code` columns + translation and note it in your report.
- [ ] **Step 6: Run the new tests and the whole backend suite**
Run: `cd /Users/tudor/projects/school_compare && uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -v`
Expected: all pass — 3 new + all pre-existing (their fixtures carry name columns; translation is a no-op on them).
- [ ] **Step 7: Commit**
```bash
git add backend/models.py backend/data_loader.py backend/tests/test_gias_translation.py
git commit -m "feat(api): translate GIAS codes to names at the query boundary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 5: Typesense sync translates before indexing
**Files:**
- Modify: `pipeline/scripts/sync_typesense.py`
**Interfaces:**
- Consumes: `pipeline/scripts/gias_codes.py` (Task 1), mart code columns (Task 3).
- Produces: identical Typesense documents to today (facet values are names).
- [ ] **Step 1: Switch the SELECT and translate**
In `sync_typesense.py`: add at the top (the DAG runs `python scripts/sync_typesense.py`, so `scripts/` is `sys.path[0]` and a plain import works):
```python
from gias_codes import PHASE_OF_EDUCATION, RELIGIOUS_CHARACTER, SCHOOL_TYPE, translate
```
In the SQL, replace `s.phase,``s.phase_code,`, `s.school_type,``s.school_type_code,`, `s.religious_character,``s.religious_character_code,`.
In the document builder, replace:
```python
"phase": row["phase"] or "",
"school_type": row["school_type"] or "",
```
with:
```python
"phase": translate(row["phase_code"], PHASE_OF_EDUCATION) or "",
"school_type": translate(row["school_type_code"], SCHOOL_TYPE) or "",
```
and:
```python
if row.get("religious_character"):
doc["religious_character"] = row["religious_character"]
```
with:
```python
religious_character = translate(row.get("religious_character_code"), RELIGIOUS_CHARACTER)
if religious_character:
doc["religious_character"] = religious_character
```
- [ ] **Step 2: Verify statically**
Run:
```bash
cd /Users/tudor/projects/school_compare && \
python3 -c "import ast; ast.parse(open('pipeline/scripts/sync_typesense.py').read()); print('sync OK')" && \
grep -n "row\[\"phase\"\]\|row\[\"school_type\"\]\|row\[\"religious_character\"\]" pipeline/scripts/sync_typesense.py; echo "stale grep exit=$? (want 1)"
```
Expected: `sync OK`, no stale name-column row accesses.
- [ ] **Step 3: Commit**
```bash
git add pipeline/scripts/sync_typesense.py
git commit -m "feat(pipeline): typesense sync translates GIAS codes before indexing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 6: Spec status, PR, deploy runbook
**Files:**
- Modify: `docs/superpowers/specs/2026-07-09-gias-code-dictionaries-design.md` (status line)
- [ ] **Step 1: Mark the spec implemented**
Change `**Status:** Approved design` to `**Status:** Implemented 2026-07-09 — see docs/superpowers/plans/2026-07-09-gias-code-dictionaries.md`.
- [ ] **Step 2: Commit and push**
```bash
git add docs/superpowers/specs/2026-07-09-gias-code-dictionaries-design.md
git commit -m "docs: mark GIAS code dictionaries spec implemented
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
git push -u origin feat/gias-code-dictionaries
```
- [ ] **Step 3: Open the PR (Gitea API via git credential fill — token-header auth 401s)**
Title: `feat: GIAS classification fields stored as codes, translated in code`
Body must include: (1) API contract unchanged — names still served, translation at the query boundary; (2) the **deploy runbook: merge → deploy → trigger `school_data_daily` immediately** (accepted empty-API window until the marts rebuild — spec §7); (3) dictionary maintenance loop (dbt drift test warns → rerun `generate_gias_codes.py` → commit regenerated files); (4) no frontend/e2e changes. End with the standard generation footer.
- [ ] **Step 4: Watch CI**
All PR checks must pass. Do not merge — merging triggers the deploy window; the human runs the runbook.
@@ -0,0 +1,79 @@
# UX/UI Audit of SchoolCompare.co.uk — Design
**Date:** 2026-07-02
**Status:** Approved design, awaiting execution
**Output of execution:** a prioritized audit report (this spec defines how that report is produced)
## Goal
A comprehensive UX and design audit of the live site (schoolcompare.co.uk) that answers:
- **(a)** Can parents reach the relevant information quickly and intuitively?
- **(b)** Is the site visually and experientially cohesive end-to-end, not just at component level?
- **(c)** Does it comply with current design standards and best practices (incl. WCAG 2.2 AA)?
Every recommendation must be argued from evidence — no change for change's sake — and carry an indication of potential uplift. Primary success lens: **engagement and task completion** (parents who land actually reach a school page or comparison); SEO benefits noted secondarily.
## Analytics baseline (30 days, informing all weighting)
- ~2.3k visitors; 87% UK; 83% Google organic.
- Devices: **56% mobile**, 41% laptop, 2% desktop → mobile is the primary viewport.
- Page views: `/` 52%, `/compare` 27%, `/rankings` 12%, `/admissions` 5%, school pages a long tail (~1% each).
- Entries: `/` 63%, `/compare` 20%, `/rankings` 6%, individual school pages a small but real SEO tail.
- Exits: `/` 46% (biggest leak), `/compare` 32%, `/rankings` 13%.
## Method
**Environment:** live production site via Playwright browser tools. Two viewports: **390×844 (mobile, weighted primary)** and **1440×900 (desktop)**. Chrome engine (matches 41% Chrome + 11% Edge majority); iOS/WebKit-specific rendering is out of scope (cannot be emulated faithfully).
**Journey-led walk-through, in traffic order:**
1. **Home → find my school** (63% of entries; 46% of exits). Can a parent with a school name or postcode reach the right school page in under ~15 seconds? What competes for attention?
2. **Google → school detail page → next step** (SEO long tail). Landing cold: is site identity obvious, is performance data comprehensible to a non-specialist parent, is there a clear path to "compare with nearby schools"?
3. **Building a comparison** (27% of views, 32% of exits). Adding/removing schools, chart legibility on mobile, metric comprehension.
4. **Rankings → shortlist** (12% of views). Filtering by local authority, scanning, jumping to school pages.
5. **Admissions content** (5% — light pass, mainly cohesion with the rest of the site).
**Per journey, record:** friction points with screenshot evidence; what works well (explicitly kept); axe-core accessibility scan of each page state visited.
**Cross-cutting cohesion pass** (after journeys): typography scale, spacing rhythm, colour usage, component variants (buttons, chips, cards, nav, empty/loading states) compared **across** pages; consistency check of recent additions (map-blended hero, characteristic chips, admissions cross-links).
## Evaluation criteria
A finding is valid only if it cites at least one of:
- a violated usability heuristic (Nielsen/NN-g),
- a WCAG 2.2 AA failure (axe result or manual check: contrast, touch targets ≥24px, keyboard/focus, labels),
- a mobile-usability standard,
- a concrete task-flow obstruction observed in the walk-through.
No "I'd prefer it differently" findings. Each finding: **evidence → argument (why it hurts parents) → recommendation**.
## Prioritization
Scored on traffic-weighted impact × severity:
- **P0 Urgent** — blocks or badly degrades a core task on a high-traffic path, or a clear WCAG failure on a main page.
- **P1 High** — meaningful friction on a main journey; strong expected funnel uplift.
- **P2 Medium** — cohesion/polish issues that erode trust but don't block tasks.
- **P3 Nice-to-have** — low-traffic pages or marginal refinements.
**Uplift indication per finding:** which analytics number it should move (e.g. "home 46% exit rate", "share of sessions reaching a school page"), direction, and a magnitude band (small/moderate/large) with reasoning. Honest bands, not invented percentages — there is no baseline funnel instrumentation.
## Deliverable
One audit report at `docs/superpowers/specs/2026-07-02-ux-audit-report.md` containing:
1. Method summary.
2. **"What works today — keep"** list.
3. Findings grouped by tier (P0P3), each with evidence, argument, recommendation, uplift indication.
4. Suggested implementation sequence grouping related fixes.
The report is the plan requested. Implementation of any fixes is a separate follow-up with its own plan.
## Out of scope
- Performance/Core Web Vitals auditing (explicitly excluded by choice of scope).
- iOS/Safari-specific rendering verification.
- Changes to data content or backend behaviour.
- Actually implementing fixes.
@@ -0,0 +1,29 @@
# Journey N: <name> — audit notes
**Pages visited:** <paths>
**Viewports:** 390×844, 1440×900
## Task attempt log
<What was attempted, step by step, and where time/attention went. Note seconds-to-goal where measurable.>
## Friction points
For each:
- **F<N>. <short title>**
- Evidence: <screenshot filename(s), observed behaviour, axe rule id if applicable>
- Criterion violated: <heuristic / WCAG SC / mobile standard / task obstruction>
- Argument: <why this hurts a parent completing the task>
- Severity guess: <P0/P1/P2/P3 — provisional, finalized in synthesis>
## Works well — keep
- <observation, with why it works>
## Axe results
- <path> @ <viewport>: <violationCount> violations — <ids with impact>
## Manual WCAG spot checks
- Touch targets ≥24px on interactive elements: <pass/fail + examples>
- Keyboard: tab order, focus visibility (desktop only): <pass/fail + examples>
- Zoom 200% text reflow (desktop only): <pass/fail>
## Screenshots
- <filename>: <what it shows>
@@ -0,0 +1,27 @@
// Body for playwright browser_evaluate: () => { ...this content... }
// Loads axe-core 4.x from CDN (skips if already present), runs WCAG A/AA scan.
return (async () => {
if (!window.axe) {
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/axe-core@4.10.2/axe.min.js';
s.onload = resolve;
s.onerror = () => reject(new Error('axe failed to load'));
document.head.appendChild(s);
});
}
const results = await window.axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] }
});
return {
url: location.pathname,
violationCount: results.violations.length,
violations: results.violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.help,
nodes: v.nodes.length,
sampleTargets: v.nodes.slice(0, 3).map(n => n.target.join(' '))
}))
};
})();
@@ -0,0 +1,148 @@
# Cross-cutting cohesion pass — audit notes (Task 7)
**Pages compared:** `/`, `/compare`, `/rankings`, `/admissions`, `/school/136916-the-castle-school` (secondary), `/school/146678-welland-primary-school` (primary)
**Viewport:** 1440×900 default; mobile 390×844 for the /compare 3-school check.
**Method:** Ran a `browser_evaluate` typography+colour collector on each of the six live pages (headings, body, buttons, links, chips — computed `fontSize/fontWeight/fontFamily`, `color/backgroundColor/borderRadius/padding`). Diffed the results into the tables below. For every ambiguous drift, grepped `nextjs-app/` (`app/globals.css` + `components/*.module.css`) to decide **token exists & bypassed (→ enforce)** vs **no token (→ create)**.
**Criterion used throughout:** Nielsen #4 "Consistency and standards" unless a WCAG SC is named. No taste-only findings — every claim below carries computed-style and/or source evidence.
---
## Task attempt log
Collected computed styles on all six pages in sequence, then verified against source. The dominant story is **two axes of drift**: (a) the two school-detail pages are *parallel component implementations* (`SchoolDetailView` vs `SecondarySchoolDetailView`) whose tokens have diverged; (b) a rich design-token system exists in `globals.css` (`--radius-*`, `--accent-*`) but is pervasively **bypassed with hardcoded px/hex** in the module CSS.
### OPEN item — /compare with THREE same-phase schools at 390×844
Loaded `/compare?urns=142161,113105,124613&metric=rwm_expected_pct` at 390×844 — three **primary** schools (St Mary & St Thomas CofE, Ottery St Mary, Trimley St Mary), same phase, confirmed via the "Primary (3) / Secondary (0)" phase tabs. Handling is sound: the three school cards **stack vertically** (each showing name, LA, type, and the selected metric value in its series colour — 77.0% teal, 51.0% coral, 44.0% blue), so no card is squeezed. The "Performance Over Time" chart canvas renders all three series (verified: canvas has drawn content — 9,720 non-white pixel samples in a 324×300 canvas; a full-page screenshot showed it momentarily blank, which is a Chart.js/`fullPage` capture artifact, not a real defect). The "Detailed Comparison" table becomes 4 columns (metric label + 3 schools) at 869px inside a 324px container: it scrolls **horizontally within its own `overflow-x:auto` wrapper**, and the page body itself does **not** overflow (`document.scrollWidth` = 390 = `window.innerWidth`). So a third same-phase school is legible and contained — no layout break. Only nit (already a general finding, not compare-specific): at 390px only the first school's column is visible without scrolling, so a 3-way visual scan of the table requires swiping. Screenshot: `cohesion-compare-3school-mobile.jpeg`.
---
## Typography comparison (per page, desktop 1440)
| Page | Page-title H1 | H1 family | Section H2/H3 | Notable body sizes | Off-scale / cross-page flags |
|---|---|---|---|---|---|
| `/` | **48px** /700 Playfair | Playfair | H2 28px & 21.6px; H3 16px & 20px Playfair | 16.8, 14.72, 14, 13.76, 13.12px | Search CTA button **20px** /600 (see F1); many near-dup body sizes |
| `/rankings` | **36px** /700 Playfair | Playfair | — | table TH 12px, TD 15px/16px | H1 smaller than home; table type 12/15 |
| `/compare` | **36px** /700 Playfair | Playfair | H2 **18px & 24px** Playfair (mixed) | table TH 12px, TD 15px | 3× **BUTTON 20px Arial** (off-family, F7); mixed H2 |
| `/admissions` | **44px** /700 Playfair | Playfair | H2 24px & 21.6px Playfair; **H3 15.2px /700 DM Sans** | 14.08, 14.4, 14, 13.12px | H3 rendered in **body font** not Playfair (F7); 3rd distinct H1 size |
| `/school/…primary` (Welland) | **52px** /700 Playfair | Playfair | H2 18px Playfair ×5; H3 14px DM Sans | table TH **11px**, TD **13px** | largest H1; table type 11/13 (F8) |
| `/school/…secondary` (Castle) | **52px** /700 Playfair | Playfair | H2 18px Playfair ×4; H3 14px DM Sans | table TH **11px**, TD **13px** | matches Welland (good); table 11/13 (F8) |
**Diff summary:** Four distinct page-title sizes across five pages — **36 / 44 / 48 / 52px**. Tool pages (rankings, compare) agree on 36; the three "hero" pages (home 48, admissions 44, school 52) each pick a different size. Source: each is a **hardcoded `clamp()`** in its own module, not a shared token — home `.hero-title` `clamp(2rem,5vw,3.5rem)`; both school `.schoolName` `clamp(2rem,5vw,3.25rem)`; admissions title `2.75rem`. → **no page-title/hero token exists → CREATE.** Body copy shows a cloud of near-duplicate sizes (0.875/0.88/0.9/0.92rem → 14/14.08/14.4/14.72px) that no single scale explains.
Fonts are otherwise disciplined: **DM Sans** body + **Playfair Display** display everywhere. Exceptions: (a) admissions/school **H3 elements render in DM Sans** while H2 stays Playfair (F7); (b) `Arial`/`Helvetica Neue` buttons+links on chart and map pages are **third-party** (Chart.js legend, Leaflet zoom/attribution) — noted, not a first-party defect.
---
## Colour comparison (buttons / links / chips)
| Role | Colour(s) observed | Token | Verdict |
|---|---|---|---|
| Primary CTA bg | `rgb(224,114,86)` `#e07256` everywhere (search, +Add School, active phase tab, btn-primary) | `--accent-coral` | Consistent ✓ |
| Primary CTA **hover** | `#c45a3f` (`btn-primary`) **vs `#d4654a`** (`.btn-compare:hover`, globals.css:754) | `--accent-coral-dark` = #c45a3f | **Near-dup / same-role different colour (F6)**#d4654a hardcoded, bypasses token |
| Secondary / supporting | `rgb(45,125,125)` `#2d7d7d` (teal outline btn, teal chips, "Near me", info) | `--accent-teal` | Consistent ✓ |
| Nav active | coral text on `rgba(224,114,86,0.12)` tint | `--accent-coral-bg` | Consistent ✓ |
| Gold accent (admissions cross-link badge on school page) | `rgb(184,146,14)` `#b8920e` | tokens are `--accent-gold #c9a227` / `--accent-gold-text #7a6800` | **Third gold** — matches neither token (F6 family) |
| Chip series colours (countdown / SATs) | coral `#e07256`, teal `#2d7d7d`, blue chart-5 | `--chart-*` | Consistent ✓ (data encoding) |
| Map furniture links | `rgb(0,120,168)` `#0078a8` blue | none (Leaflet/OSM) | Third-party — off-palette blue leaks into school hero (see hero verdict) |
**Cluster flags:** coral resolves to **three** values doing hover/pressed work — `#e07256`, `#c45a3f`, `#d4654a`; the middle two are the same semantic role (pressed coral) at different hex. Gold has a third off-token value `#b8920e`. Teal is clean. No different-role/same-colour collisions found (coral=primary, teal=secondary is held consistently).
---
## Component variant table
| Component / role | `/` | `/rankings` | `/compare` | `/admissions` | school (primary) | school (secondary) | Drift |
|---|---|---|---|---|---|---|---|
| Segmented / phase switcher | search-mode toggle: **rounded** (container radius-lg, btn radius-md), coral active | phaseTab: **radius 0**, coral fill, bordered | phaseTab: **radius 0** (matches rankings ✓) | sub-nav: **radius 0**, underline, teal active | section-nav pills radius 4/999 | tab btns radius 4 | **F5** — 3+ different treatments for "switch view/phase" |
| Primary "+Compare/+Add" button | btn-primary radius 8, pad 20×40 (F1) | btn radius 8, pad 12×24 | btn radius 8, pad 12×24 | — | btnAdd **radius 8, pad 12×20** | btnAdd **radius 6, pad 8×16** | **F1 / F2** |
| Back link | — | — | — | — | topBack coral, radius 0 | topBack coral, radius 0 | consistent ✓ |
| Data table type | — | TH 12 / TD 15 | TH 12 / TD 15 | — | TH **11** / TD **13** | TH **11** / TD **13** | **F8** |
| Small badge/pill | ofsted badge radius 4 | rank badge 50% | — | deadline chip radius 12 | SATs natPill radius **4**; nav pill 999 | badge radius **3**; att8 badge radius **3** | **F4** |
| Nav header / footer | identical | identical | identical | identical | identical | identical | **consistent ✓** |
Radius scale audit across all `*.module.css`: **15 distinct raw-px radius values** in use — 4px(30×), 8px(28×), 999px(21×), 12px(19×), 3px(16×), 6px(14×), 10px(13×), 2px(8×), 16px(4×), 14px(3×), 9999px(2×), 1px(2×), 9px, 5px, 25px — against a token scale of only `--radius-sm/md/lg/xl` = 4/8/16/24. Tokens exist and are widely **bypassed**; there is **no pill token** for the 999/9999 values.
---
## Friction points
- **F1. Duplicate `.btn` rule set in `globals.css` — small-button padding is dead code, sizes drift**
- Evidence: `globals.css` defines `.btn` **twice** — line 151 (radius 6px, pad `0.5rem 1rem`, font 0.875rem, 1px border) and again line 1558 (radius `--radius-md`, pad `0.75rem 1.5rem`, font 0.9rem, `border:none`). `.btn-sm` (line 218, pad `0.3rem 0.625rem`) is declared *between* them, so the later `.btn` (equal specificity, source order wins) **overrides** it. Live proof: rankings `.btn.btn-sm` computes to padding **12px 24px**, not the intended 4.8×10px. Meanwhile the home search `.btn.btn-primary` computes to pad **20px 40px** / font 20px (a FilterBar override on top).
- Criterion violated: Nielsen #4; touch-target predictability.
- Argument: "small" buttons aren't small, and the base button geometry depends on which of two conflicting blocks wins — any future button edit has a 50/50 chance of hitting the dead rule. Silent, repo-wide.
- Source verdict: **token/rule conflict → ENFORCE** (dedupe to one `.btn` definition; restore `.btn-sm`).
- Severity: **P2**.
- **F2. Two parallel school-detail components have drifted on the same controls**
- Evidence: `SchoolDetailView.module.css` vs `SecondarySchoolDetailView.module.css` implement the same UI with divergent hardcoded values: `.btnAdd` **radius 8px / pad 0.75rem 1.25rem** (primary) vs **radius 6px / pad 0.5rem 1rem** (secondary); national-average marker `.natPill` radius **4px** (primary) vs `.badge`/`.att8` radius **3px** (secondary); section-tab padding `4.8px 10px` vs `4.8px 12px`. Both files hardcode px rather than referencing `--radius-md`.
- Criterion violated: Nielsen #4.
- Argument: a parent moving from a primary school page to a secondary one (the compare flow explicitly mixes phases) meets the "+ Compare" button and nav tabs rendered at subtly different sizes/corners — the classic "two things that should be one" tax, and double the maintenance surface.
- Source verdict: token EXISTS (`--radius-md:8px`) but **bypassed → ENFORCE** (both should use the token; ideally share one component).
- Severity: **P2**.
- **F3. Page-title (H1) sizing is unsystematic across page types**
- Evidence: H1 computes to **36px** (rankings, compare), **44px** (admissions), **48px** (home), **52px** (school). Source: each is a separate hardcoded `clamp()`/rem in its own module (home `clamp(2rem,5vw,3.5rem)`; school `clamp(2rem,5vw,3.25rem)`; admissions `2.75rem`); tool pages fall back to smaller local values.
- Criterion violated: Nielsen #4 (visual hierarchy consistency).
- Argument: page-to-page the "you are here" title jumps size with no rule a user could infer; hero pages don't even agree with each other.
- Source verdict: **no shared page-title/hero token → CREATE** (`--title-hero`, `--title-section`) and apply.
- Severity: **P2** (hierarchy), leaning P3 in isolation.
- **F4. Radius scale is bypassed system-wide (15 raw values vs 4 tokens; no pill token)**
- Evidence: radius audit above — 3/5/6/9/10/12/14/25px and 999/9999px all appear hardcoded despite `--radius-sm/md/lg/xl`. Same-role badges differ (natPill 4 vs secondary badge 3; ofsted badge 4 vs att8 badge 3).
- Criterion violated: Nielsen #4.
- Argument: corner rounding is a primary "family resemblance" cue; with 15 values it reads as many hands, not one system.
- Source verdict: **mixed** — tokens exist for 4/8/16 (**ENFORCE**); pill radius has **no token → CREATE** `--radius-pill: 999px`.
- Severity: **P2**.
- **F5. "Switch view / phase" control has 3+ different treatments**
- Evidence: home search-mode toggle is a **rounded** segmented control (container `--radius-lg`, coral active); rankings & compare phase tabs are **square** (radius 0) bordered coral-fill tabs; admissions sub-nav is an **underline** tab strip (radius 0, teal active). Same job, three shapes and two accent colours.
- Criterion violated: Nielsen #4.
- Argument: the segmented switch is a recurring interaction; users re-learn it on each page. (Rankings↔compare agreeing is the one bright spot.)
- Source verdict: **no shared segmented-control component → CREATE/CONSOLIDATE**.
- Severity: **P2**.
- **F6. Near-duplicate accent colours for the same role**
- Evidence: pressed/hover coral is `--accent-coral-dark #c45a3f` on `.btn-primary` but a **hardcoded `#d4654a`** on `.btn-compare:hover` (globals.css:754); gold appears as `#b8920e` on the school-page admissions cross-link badge, matching neither `--accent-gold #c9a227` nor `--accent-gold-text #7a6800`.
- Criterion violated: Nielsen #4.
- Argument: two hovers for the same "coral button being pressed" is exactly the "two blues doing the same job" consistency defect.
- Source verdict: token EXISTS → **ENFORCE** (`--accent-coral-dark`); the off-token gold → ENFORCE `--accent-gold-text`.
- Severity: **P3**.
- **F7. Heading font-family and off-family buttons break the type system locally**
- Evidence: on `/admissions` and both school pages, `H3` elements render in **DM Sans /700** while `H2` stays Playfair — an inconsistent semantic-heading treatment. Separately, `/compare` shows 3× `BUTTON` in **Arial 20px** and school pages show Arial/Helvetica-Neue controls.
- Criterion violated: Nielsen #4 (the H3 case). The Arial/Helvetica cases are **third-party** (Chart.js legend toggles, Leaflet zoom/attribution) — recorded as environmental, not a first-party fix.
- Argument: an H3 in body font reads as a bold paragraph, weakening the Playfair hierarchy the rest of the site sells.
- Source verdict: H3 font-family is set locally, no heading-family token discipline → **ENFORCE** Playfair for display headings (or intentionally reclass those H3s as labels).
- Severity: **P3**.
- **F8. School-detail data tables use a smaller type scale than the shared data tables**
- Evidence: rankings & compare tables compute **TH 12px / TD 15px**; both school-detail tables compute **TH 11px / TD 13px**.
- Criterion violated: Nielsen #4; borderline WCAG 1.4.4 (13px data is small but resizable).
- Argument: the same kind of KS2 figures appear one size on compare and a size smaller on the school page — inconsistent, and the smaller variant is the one a parent studies most.
- Source verdict: table type is set per-component, **no shared table-type token → CREATE** and apply.
- Severity: **P3**.
---
## Recent-additions verdicts (native vs bolted-on)
- **Map-blended hero (`SchoolHeroMap` on school pages): mostly native, with a third-party seam.** The framing is on-brand — coral back-link, coral "+ Compare", cream surround, Playfair title over the map. But the embedded Leaflet layer imports **off-palette blue `#0078a8` attribution links and Arial zoom controls** straight into the hero (F7), the one place they're most visible. Verdict: **native design, bolted-on furniture** — worth restyling the Leaflet attribution/controls to the palette.
- **Characteristic chips (school rows + detail badges): native.** Tints use `--accent-teal-bg`/gold tints that belong to the palette, tone is quiet per the recent commit. Only blemish is radius drift (badge 3px vs natPill 4px, F4) — a token issue, not a stylistic mismatch.
- **Admissions cross-links: native in colour, inconsistent in treatment.** The teal `stepTool`/`navLink` links match the accent system, but the *same* "go to a tool" intent is a plain underlined **text link** on `/admissions` yet a **gold badge (`#b8920e`)** on the school page (F6) — and journey-5 already logged one such cross-link at a 39px tap target. Verdict: **native palette, slightly bolted-on** because the cross-link component isn't unified.
---
## Works well — keep
- **Header + footer are pixel-identical on all six pages** (logo Playfair, coral nav-active tint, dark footer with faded links) — the strongest cohesion anchor on the site.
- **Coral = primary / teal = secondary** is held consistently for button and link roles (no role/colour collisions).
- **Rankings and compare phase tabs are genuinely shared** (radius 0, pad 10×24, coral active) — the model for what F5 should become everywhere.
- **Both school-detail H1s agree at 52px**, and the deadline countdown chip is byte-for-byte identical between the homepage widget and `/admissions` (radius 12px, pad 16px 17.6px 14.4px) — a correctly reused component.
## Self-review
- No taste-only findings: every F cites either a computed-style diff (F1/F2/F3/F4/F6/F7/F8 all carry live px/hex) or a shared-component absence (F5), plus a criterion.
- Token-vs-no-token recorded for every source-checked finding: **ENFORCE** — F1, F2, F4(4/8/16), F6, F7; **CREATE** — F3 (hero title token), F4 (pill radius token), F5 (segmented-control component), F8 (table-type token).
- Third-party styling (Chart.js Arial, Leaflet blue/Arial) is explicitly separated from first-party defects rather than filed as findings.
- Severity spread: **P2 ×5** (F1, F2, F3, F4, F5), **P3 ×3** (F6, F7, F8). No P0/P1 — nothing blocks a task; the journey-5 contrast WCAG failure is already logged there and not re-filed here.
## Screenshots (referenced, not committed)
- `cohesion-compare-3school-mobile.jpeg`: `/compare` at 390×844 with three same-phase primary schools — cards stack, chart renders, table scrolls within its container.
@@ -0,0 +1,108 @@
# Journey 1: Home → find my school — audit notes
**Pages visited:** `/`, `/?search=Welland+Primary` (results), `/?search=B91+3` (postcode results), `/school/146678-welland-primary-school`
**Viewports:** 390×844 (primary), 1440×900 (+720×450 for 200% reflow)
**Site:** live https://schoolcompare.co.uk only. Read-only: searched/filtered, submitted no data-modifying forms.
## Task attempt log
**Mobile (390×844) — Attempt A: find a school by name.**
- Home loads with search input above the fold (input top 176px / bottom 246px of an 844px viewport, 70px tall — comfortable tap target). H1 "Every school in England, *compared.*" at 85px. `Schools near me` button at ~388px. Full page is 3132px tall; content order is search → near-me → admissions-deadline rail → "Start exploring" links → marketing ("What you'll see", "About school data") → footer. **Primary task is first — good.**
- Typed `Welland Primary` (15 keystrokes). **No live autocomplete/typeahead appeared** while typing.
- Tapped `Search`. URL → `/?search=Welland+Primary`. Result was instant (no perceptible wait; no spinner needed). "1 school found", correct school ranked first, rich card: `Good · 2023`, Primary, Academy converter, Ages 411, 74% RWM with down-trend arrow (prev 76%), +12 pts vs national, 131 pupils, Worcestershire.
- Tapped `View``/school/146678-welland-primary-school`, instant.
- **Time-to-school-page: 3 taps (searchbox, Search, View) + typing, effectively instant load. Well under the ~15s target.**
**Mobile — Attempt B: find schools by postcode `B91 3` (Solihull).**
- Same 3-interaction pattern (focus box, type, Enter). URL → `/?search=B91+3`. "8 schools found."
- **Results show NO distance and are NOT sorted by proximity** (sort = "Relevance"). The list mixes phases and the **top result is a Secondary school** (Tudor Grange Academy), followed by an independent all-through, another secondary, then primaries, and a Sixth Form College last. Behaviour is consistent with a postcode-prefix text match against the postcode column, not a geographic radius search.
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
## Friction points
- **F1. Search has no autocomplete / typeahead suggestions**
- Evidence: Typing `Welland Primary` (mobile & desktop) produced no suggestion dropdown at any keystroke; snapshots show only the raw input until submit. Screenshot `j1-home-mobile-fold.png`.
- Criterion violated: Nielsen #6 (Recognition rather than recall) + established site-search usability (NN-g "search suggestions"); WCAG-adjacent error-prevention.
- Argument: Parents frequently don't know a school's exact registered name ("Welland" vs "Welland Church of England"). With no suggestions and an exact-ish match required, a misspelling risks a zero-result dead end on the highest-traffic path, and every search costs full typing + a blind submit.
- Severity guess: P1
- **F2. Postcode search shows no distance and isn't sorted by proximity** — **⚠ REVISED after recheck, see addendum at end of file: this holds only for PARTIAL postcodes; a full postcode triggers a working proximity search with distances.**
- Evidence: `/?search=B91+3` → "8 schools found", sort "Relevance", top result a Secondary school, phases mixed (secondary/independent/primary/sixth-form), zero distance shown on any card. Screenshot `j1-postcode-results-mobile.png`.
- Criterion violated: Nielsen #2 (Match between system and the real world) + task-flow obstruction. The placeholder invites a "postcode", which sets an expectation of "nearest schools first"; the delivered value (proximity ordering + distance) is missing.
- Argument: On the core "find my school near me" task, a parent who types their postcode expects the closest schools ranked by distance. Instead they get an unordered, distance-less, mixed-phase list led by a secondary school — it reads as broken and gives no way to judge "which is closest," pushing them to abandon. (Name search works, so the overall journey isn't fully blocked → P1 not P0, but this is the weakest link on the primary task.)
- Severity guess: P1 (candidate P0 for the postcode sub-path)
- **F3. Filter and Sort dropdowns on the results view have no accessible name**
- Evidence: axe `select-name` (impact **critical**, 2 nodes) on `/?search=Welland+Primary`; targets `.FilterBar-module…controlSelect` (phase filter) and `.HomeView-module…sortSelect`.
- Criterion violated: WCAG 2.2 **4.1.2 Name, Role, Value (Level A)**.
- Argument: These are the exact controls a parent uses to narrow a mixed result set to "Primary" or re-sort — and a screen-reader user hears an unlabelled combobox, so cannot tell what either does. This is on the main journey's results view (same `/` route), a clear Level-A failure.
- Severity guess: P1 (candidate P0 — WCAG A failure on the main route)
- **F4. Serious colour-contrast failures on the home page**
- Evidence: axe `color-contrast` (impact serious) — 6 nodes on home (`/`), incl. the active nav tab label, `.btn`, and "how-it-works" step text; 3 nodes on results (incl. `.btn-primary`, Ofsted badge).
- Criterion violated: WCAG 2.2 **1.4.3 Contrast (Minimum) (AA)**.
- Argument: Low-contrast labels and buttons are harder to read for low-vision parents and for everyone on a phone in daylight — the mobile-primary audience. Affects the main CTA styling and the active-tab indicator.
- Severity guess: P1
- **F5. Admissions-deadline rail: keyboard-inaccessible scroll region, defaults to the least-urgent card**
- Evidence: axe `scrollable-region-focusable` (serious, 1 node) on `.countdownRail`; on mobile the rail's default scroll position lands on the 4th card ("Primary Offer Day · 288 days") rather than the nearest deadline ("Secondary · 121 days"), with only a partial card peeking as the scroll cue. Screenshots `j1-home-mobile-fold.png`, `j1-home-mobile-full.png`.
- Criterion violated: WCAG 2.2 **2.1.1 Keyboard (A)** for the scroll region; Nielsen #1 (Visibility of system status) for the ordering.
- Argument: Keyboard/switch users can't reach the later cards, and the odd default scroll buries the single most time-critical deadline (121 days) behind less urgent ones — the opposite of what an anxious parent needs surfaced.
- Severity guess: P2
- **F6. Mobile hero omits the value proposition shown on desktop**
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "24,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
## Works well — keep
- **Name search is genuinely excellent.** `Welland Primary` returned the correct school as the sole top result with an information-rich card (Ofsted grade+year, RWM % with year-over-year trend arrow, "+12 pts vs national", pupil count, LA) — 3 taps + typing, instant load, comfortably under the 15s target. This is the journey's strongest asset.
- **Search is the unmistakable primary action.** Prominent, above the fold on both viewports, large (70px) tap target, plain-English placeholder "School name or postcode", single clear "Search" CTA; content order puts the task first.
- **Accessibility fundamentals partly in place:** a working "Skip to main content" link (keyboard-focusable, visible outline), logical tab order, and strong 2px focus outlines on nav/skip/button.
- **Robust responsive reflow:** 200%/720px shows no horizontal scroll and no overlap; deadline cards reflow cleanly.
- **Result cards adapt to phase** (Attainment 8 for secondary, RWM % for primary) — good match to the real-world data a parent expects for each phase.
- **A real proximity path exists** via the "Schools near me" geolocation button (distinct teal styling) — the raw material for good location search is present, just not wired to the postcode text input (see F2).
## Axe results
- `/` (home, initial) @ 390×844: **2 violations**`color-contrast` (serious, 6 nodes), `scrollable-region-focusable` (serious, 1 node).
- `/?search=Welland+Primary` (results) @ 390×844: **2 violations**`color-contrast` (serious, 3 nodes), `select-name` (**critical**, 2 nodes).
- Desktop (1440×900) serves the same DOM/CSS; violations above apply equally (not separately re-scanned).
## Manual WCAG spot checks
- **Touch targets ≥24px:** Pass on the core path — search input 70px tall, Search button large, "Schools near me" 40px, result-card `View`/`+ Compare` full-size. Watch: the small "×" chip on the active-search token is borderline.
- **Keyboard: tab order, focus visibility (desktop):** Pass with a caveat — order is logical and skip/nav/buttons show a clear 2px orange outline; the search **input** relies on an orange border + a very faint 0.12-alpha ring (visible but the weakest indicator on the page — borderline for 2.4.11 Focus Appearance).
- **Zoom 200% text reflow (desktop):** Pass — no horizontal scroll at 720px, no clipping/overlap.
## Why do 46% of visitors exit at home? — observed plausible causes
1. **Postcode expectation mismatch (F2).** Many parents will type their postcode expecting "nearest schools, closest first." They get a distance-less, unsorted, mixed-phase list led by a secondary school — it looks broken, so they leave. This is the biggest task-level leak on the primary journey.
2. **Weak mobile value proposition (F6).** On the 63%-of-entries mobile hero, the vague H1 + bare search box give a first-time visitor no reason to trust or continue; no coverage/freshness/data statement above the fold. Classic bounce driver.
3. **No search assistance (F1).** Typing a school name with zero suggestions and requiring a near-exact match means a spelling slip → likely a zero-result dead end → exit.
4. **Trust/polish erosion (F3, F4).** Critical unlabelled controls and serious contrast failures degrade the "credible data source" impression, especially for the accessibility-dependent and daylight-mobile segments.
5. **Benign exits (nuance for synthesis).** Some "home exits" are successes, not failures: a user who finds their school card and taps an outbound link, or who reads the answer and leaves satisfied. Not the entire 46% is a usability leak — but causes 14 are the addressable share.
## Screenshots
- `j1-home-mobile-fold.png`: mobile home above the fold — H1 + prominent search box + Search CTA, deadline rail beginning.
- `j1-home-mobile-full.png`: full mobile home — content order (search → near-me → deadlines → explore → marketing → footer).
- `j1-search-results-mobile.png`: mobile results for "Welland Primary" — correct single result, rich card.
- `j1-postcode-results-mobile.png`: mobile results for "B91 3" — no distance, relevance sort, secondary school first.
- `j1-home-desktop-fold.png`: desktop home — includes trust badge + value-prop subheading absent on mobile.
- `j1-search-results-desktop.png`: desktop results for "Welland Primary".
- `j1-focus-searchbox-desktop.png`: keyboard focus state on the search input (orange border + faint ring).
- `j1-home-zoom200-desktop.png`: full home at 720px (≈200% reflow) — no horizontal scroll, cards reflow to 2×2.
- `j1-fullpostcode-results-mobile.png` (recheck): mobile results for full postcode "B91 3DL" — "13 schools within 1.0 miles", distance on every card, nearest-first.
## Recheck addendum (2026-07-02) — F2 tested with a FULL postcode
The original Attempt B used the partial postcode `B91 3`. Rechecked with the full postcode **`B91 3DL`** typed into the home search box and submitted:
- The submit handler (`FilterBar.tsx` `isValidPostcode()`, full-postcode regex `/^[A-Z]{1,2}[0-9][A-Z0-9]?\s*[0-9][A-Z]{2}$/i`) recognised it and routed to **`/?postcode=B91+3DL&radius=1`** — the geocoded radius search, not text search.
- Result: heading **"13 schools within 1.0 miles of B91 3DL"**, **every card shows distance** ("0.1 mi", "0.2 mi" … "1.0 mi"), **sorted nearest-first**, plus a "Within: 0.5/1/3/5 miles" radius selector, a List/Map toggle, and a "Nearest first" sort option. Screenshot `j1-fullpostcode-results-mobile.png`. This is exactly the experience F2 asked for — it exists and works well.
- **F2 as originally stated is therefore wrong for full postcodes.** The residual, real finding is narrower: input that *looks like* a location but fails the full-postcode regex — outcodes ("B91"), partials ("B91 3"), postcodes with typos — **silently falls back to name/address text search** (`/?search=…`) with no distances, relevance ordering, and no notice that a full postcode would unlock proximity search. The two result pages look similar enough that a parent won't know which mode they're in. Severity re-guessed at P1→P2 (degraded sub-path with a working primary path and a working "Schools near me" alternative; the failure is silent-mode-switching, not a missing capability).
- Exit-cause list item 1 ("postcode expectation mismatch") should be read with this narrower scope: it applies to partial/malformed postcode input only.
@@ -0,0 +1,115 @@
# Journey 2: Cold landing on a school detail page (SEO long tail) — audit notes
**Pages visited:** `/school/136916-the-castle-school` (secondary), `/school/146678-welland-primary-school` (primary), `/compare` (reached via the compare CTA)
**Viewports:** 390×844 (primary), 1440×900 (+720×450 for 200% reflow)
**Site:** live https://schoolcompare.co.uk only. Read-only: navigated, tapped "Add to Compare", switched chart tabs; submitted no data-modifying forms.
## Task attempt log
**Cold-landing premise:** each school URL was the *first* navigation into the site (simulating a Google arrival), so first-screen orientation is judged with zero prior context.
**Mobile (390×844) — Cold landing A: The Castle School (secondary, Somerset).**
- Above the fold, top→bottom: header (logo + Search/Compare/Rankings/Admissions), "← Back", a **Leaflet map**, then the school identity block. **The single most important element — the H1 "The Castle School" — is crushed between two overlapping elements:** the map (bbox bottom 280px) sits over the top of the H1 (top 272px) and the floating **"+ Add to Compare"** button (top 286px) sits over its lower half (H1 bottom 309px). Only a ~6px band of the name is unobscured. Screenshot `j2-school-mobile-fold.png` shows the name almost illegible. So the answer to "what school is this?" — the first cold-landing question — is broken on the primary viewport.
- "What site is this?" is answerable (SchoolCompare logo + bottom tab bar). "What does the site offer?" is *not* stated on the school page — no value proposition or "compare schools" framing above the fold.
- Scrolled the full 3,748px page. Section order: Ofsted → GCSE Results (2024/25) → Admissions → Historical Results → Wellbeing & Context → footer. Screenshots `j2-school-mobile-full.png`, `j2-school-mobile-gcse.png`, `j2-school-mobile-history.png`.
- **Ofsted:** clear ("Outstanding"), dated (Inspected 3 Oct 2023), links to the real Ofsted report, and includes a current-aware note about the post-Sept-2024 grading change. Comprehensible.
- **GCSE:** rich and comparison-anchored (Attainment 8 53.4 / National avg 39.1 / "+14 pts"), with a plain caption and a "treat Progress 8 with caution" note. **But the jargon is unexplained in place:** "Attainment 8", "Progress 8", "EBacc average point score 4.72" have no tooltip/expander/definition (0 info affordances found in DOM). The plain-English definition *exists on the site* ("Average grade across a pupil's best 8 GCSEs including English and Maths") but only appears on `/compare`, not where a parent first meets the term.
- **Historical chart:** renders on scroll (the blank in the full-page capture was a lazy-render artifact, not a bug). Series = solid teal (school) + grey dashed (national) with **no visible legend**, and a data gap 2018/19→2023/24 is bridged by the line.
- **Admissions:** legible; surfaces the decision-critical "⚠ Applications exceeded places last year" and honestly states cut-off data is unavailable.
- **Next-step paths.** "+ Add to Compare" exists (overlapping the H1). There is **no "schools near this one" / "similar schools" module anywhere on the page** — the page ends at Wellbeing → footer.
- **Taps to a comparison including this school:** Tap 1 = "+ Add to Compare" (button → "✓ In Comparison", bottom Compare tab shows a "1" badge; no toast). Tap 2 = Compare tab → `/compare?urns=136916`, "Comparing 1 school". Screenshots `j2-school-mobile-compare-added.png`, `j2-compare-oneschool-mobile.png`. So **2 taps to a compare *view* containing the school, but a real 2-school comparison requires "+ Add School" then a manual name search** — because no nearby/similar list is offered, the parent must already know the competitor's name.
**Mobile — Cold landing B: Welland Primary School (primary, Worcestershire) — contrasting data.**
- **Same H1-overlap bug recurs** (map bottom 264 > H1 top 256; "+ Add to Compare" top 270 < H1 bottom 278) — confirming it is *systemic to the school template*, not school-specific. Screenshot `j2-welland-mobile-full.png`.
- Primary (KS2) page handles the same data class **markedly better**: caption "End-of-primary-school tests taken by Year 6 pupils"; a standout in-place explainer — *"Why is combined lower? A pupil is only counted if they met the bar in all three subjects…"* — which answers a real parent question without leaving the page; subject bar charts carry a legend (Expected standard / Exceeding / National average); "Pupil Premium 26.0% — Pupils from disadvantaged backgrounds" is expanded.
- **Missing-data handling is graceful:** "*No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19*" and "Historical distance cut-off data is not available… Contact the admissions authority." No broken/empty UI. No layout breakage beyond the shared H1 overlap.
**Desktop (1440×900) — reload of The Castle School.**
- Fold is clean (`j2-school-desktop-fold.png`): H1 large and legible (the map's ~8px overlap is imperceptible with the compare button moved to the top-right), full section-nav pills visible (Top/Ofsted/GCSEs/Admissions/History/Wellbeing), and a persistent **"1 school selected" compare tray** at bottom-centre (a desktop-only affordance mobile lacks).
- Chart legibility: bars and axis labels are clear; the "Nat avg 39.1" badge overlaps the chart *heading* text (minor).
- Keyboard: logical order (skip → logo → nav → Back → …), every stop shows a **2px solid orange (#E07256) focus outline**, including the in-`main` "← Back" button.
- Link affordance: in-content links (School website, Ofsted reports) are teal with **no underline** — distinguished from body text by colour plus an "↗" glyph for external links; "View on map" is an orange button. Clickable-looking to sighted users but colour-dependent.
## Friction points
- **F1. School name (H1) is overlapped/obscured by the map and the "Add to Compare" button on mobile — systemic**
- Evidence: Measured bboxes on both schools — Castle: map bottom 280 / H1 top 272 / compare-btn top 286 / H1 bottom 309; Welland: map bottom 264 / H1 top 256 / compare-btn top 270 / H1 bottom 278. Screenshots `j2-school-mobile-fold.png`, `j2-welland-mobile-full.png`.
- Criterion violated: Nielsen #1 (Visibility of system status) & #8 (Aesthetic & minimalist design); task-flow obstruction of cold-landing orientation; overlap defeats WCAG 1.4.10 Reflow's intent (content should not be obscured on the primary viewport).
- Argument: On a Google cold landing the very first question is "is this the school I searched for?" The H1 that answers it is crushed to a ~6px sliver between the map and a CTA on the 390px viewport — the exact first-screen orientation failure that drives immediate back-to-search bounces, on the site's highest-traffic SEO template.
- Severity guess: P1 (candidate P0 — primary content, main template, primary viewport)
- **F2. No "schools near this one" / similar-schools path — the compare value prop has no on-ramp**
- Evidence: Full-page snapshots of both schools show sections Ofsted→GCSE/KS2→Admissions→History→Wellbeing→footer with **no nearby/similar-schools module**. Reaching a 2-school comparison requires Compare → "+ Add School" → manual name search (`j2-compare-oneschool-mobile.png`).
- Criterion violated: Nielsen #7 (Flexibility & efficiency of use); task-flow obstruction — the site's core differentiator ("compare") is unreachable from the highest-traffic entry point without prior knowledge.
- Argument: A parent landing cold on one school has nothing to weigh it against and no way to discover the alternatives they came to compare; the compare tool assumes they already know competitor names. At the moment of maximum intent, the site offers no lateral discovery, so the parent bounces back to Google to find other local schools.
- Severity guess: P1
- **F3. GCSE jargon (Attainment 8, Progress 8, EBacc) unexplained in place on the secondary school page**
- Evidence: GCSE section shows "Attainment 8 score 53.4", "EBacc average point score 4.72" with national averages but no definitions; 0 tooltip/info affordances in the DOM. The plain-English definition exists only on `/compare` ("Average grade across a pupil's best 8 GCSEs including English and Maths"). Screenshot `j2-school-mobile-gcse.png`.
- Criterion violated: Nielsen #2 (Match between system and the real world) & #10 (Help & documentation).
- Argument: A non-specialist parent cannot judge whether "53.4" or "4.72" is good without knowing the scale/meaning. The site *has* the explanation but withholds it at first contact, forcing recall or a bounce. The national-average anchoring softens this but does not define the metric. (The primary page does this far better — see Works well.)
- Severity guess: P2
- **F4. Serious colour-contrast failures on the school template**
- Evidence: axe `color-contrast` (serious, **13 nodes**) on the Castle page — sample targets `…backBtn` ("← Back"), `…mapLink` ("View on map"), `…tabBtnActive` (active metric tab). Recurs from Journey 1 F4 (also seen on home).
- Criterion violated: WCAG 2.2 **1.4.3 Contrast (Minimum) (AA)**.
- Argument: Low-contrast Back, "View on map" and active-tab text are harder to read for low-vision parents and for everyone on a phone in daylight — the mobile-primary audience — on the main content template.
- Severity guess: P1
- **F5. Trend/results charts expose no text alternative to assistive tech**
- Evidence: axe `role-img-alt` (serious) on the `<canvas>` chart on **both** pages (Castle GCSE trend; Welland KS2 chart `canvas[height="220"]`). WCAG-mapped tag failure.
- Criterion violated: WCAG 2.2 **1.1.1 Non-text Content (A)**.
- Argument: The key data visualisation — performance over time — is invisible to a screen-reader parent; they get no equivalent from the chart. A "View raw year-by-year data" expander partially mitigates the trend chart, but the canvas itself still announces nothing. Level-A failure on the main template.
- Severity guess: P1
- **F6. Chart interpretation aids are weak: no legend for the national-average series; label overlap**
- Evidence: Historical trend chart shows a grey dashed line (national) with no visible legend (`j2-school-mobile-history.png`); the "Nat avg 39.1" badge overlaps the "ATTAINMENT 8 — SCHOOL VS NATIONAL" heading (`j2-school-mobile-gcse.png`).
- Criterion violated: Nielsen #1 (Visibility of system status) & #8 (Aesthetic & minimalist); task-flow (data interpretation).
- Argument: A non-specialist can't reliably tell what the second (dashed) line represents without a legend, undermining the core "vs national" comparison; the badge/heading overlap erodes the polish that signals a trustworthy data source. (Notably, the *primary* subject charts DO carry a legend — inconsistent.)
- Severity guess: P2
- **F7. Sticky in-page section nav clips its last items on mobile with no scroll affordance**
- Evidence: On 390px, "History" (right edge 397px) and "Wellbeing" (right 480px) render off-screen; the nav row is 448px wide and is not horizontally scrollable (document scrollWidth stays 390 — clipped, not scrollable). Screenshot `j2-school-mobile-gcse.png` (nav shows "…Admissions Histo").
- Criterion violated: Mobile usability (interactive content must be reachable within the viewport); Nielsen #7 (Flexibility & efficiency).
- Argument: 2 of 6 in-page jump links are unreachable via the sticky nav on the primary viewport, so a parent can't quickly jump to the SEN/Wellbeing or History data — they must hunt by scrolling, weakening the nav's purpose. (Content is still reachable by scrolling, so not a hard block.)
- Severity guess: P2
- **F8. "Add to Compare" confirmation is easy to miss**
- Evidence: After tapping, no toast/live-region message fired (`role=alert`/`role=status` empty); feedback is only the button relabel ("✓ In Comparison") — which sits in the overlapped/obscured H1 zone (F1) — plus a small "1" badge on the bottom Compare tab.
- Criterion violated: Nielsen #1 (Visibility of system status).
- Argument: The relabel is reasonable feedback, but because it lands in the visually crowded overlap area and there's no explicit confirmation or "view your shortlist" nudge, a parent may not register that the action succeeded or know where the shortlist lives.
- Severity guess: P3
## Works well — keep
- **Strong identity + facts block (when not overlapped).** Both pages lead with a located map plus address, headteacher, official school website, MAT/trust, and pupils-vs-capacity — exactly the orientation facts a cold visitor needs. The raw material is excellent; only the F1 overlap spoils it on mobile.
- **The primary (KS2) page explains its data in plain English.** Caption "End-of-primary-school tests taken by Year 6 pupils"; the "Why is combined lower?" explainer answers a genuine parent question *in place*; subject charts carry a legend. This is the model the secondary page (F3/F6) should follow.
- **Everything is anchored to the national average** ("+14 pts", "National avg 39.1", "National avg 62%") — lets a non-specialist judge good/bad without leaving the page.
- **Graceful, honest missing-data handling.** "No data for 2019/20 or 2020/21 — national assessments cancelled due to COVID-19"; "Historical distance cut-off data is not available… contact the admissions authority." No broken or empty UI where data is absent.
- **Ofsted section is clear, current, and trustworthy.** Grade + inspection date, the post-Sept-2024 grading-change note, and a link to the actual Ofsted report.
- **Decision-critical admissions fact is surfaced** ("⚠ Applications exceeded places last year") rather than buried in a table.
- **Desktop is clean and accessible.** No H1 overlap, full section nav visible, persistent compare tray, legible charts; logical keyboard order with a visible 2px orange focus outline on every control incl. the in-`main` Back button; 200% reflow (720px) has no horizontal scroll.
- **Compare metric definitions exist** (on `/compare`) and the shortlist persists via localStorage — the explanatory content is written, just not surfaced on the detail page.
## Axe results
- `/school/136916-the-castle-school` @ 390×844 & 1440×900 (same DOM/CSS): **2 violations**`color-contrast` (serious, 13 nodes: backBtn, mapLink, tabBtnActive), `role-img-alt` (serious, 1 node: chart `canvas`).
- `/school/146678-welland-primary-school` @ 390×844: **1 violation**`role-img-alt` (serious, 1 node: `canvas[height="220"]` KS2 chart). No `color-contrast` violation was reported on this page's rendered DOM.
## Manual WCAG spot checks
- **Touch targets ≥24px:** Pass (marginal). "← Back" button measures 64×28px (meets the 24px minimum but is the smallest); "+ Add to Compare", metric tabs and section-nav pills are comfortably sized. WCAG 2.5.8 Target Size (Minimum) met.
- **Keyboard: tab order, focus visibility (desktop):** Pass — order is logical (skip → logo → nav → Back → content), and every stop including the in-`main` Back button shows a clear 2px solid orange (#E07256) outline.
- **Zoom 200% text reflow (desktop):** Pass — at 720px width the school page has no horizontal scroll (scrollWidth == clientWidth == 720).
## "Parent lands here from Google — what makes them stay vs bounce?"
**Would stay because:** the page answers the real questions — Ofsted grade (with a link to the source and an up-to-date grading note), results anchored to the national average, admissions pressure ("applications exceeded places"), and — on primary — plain-English explanations and honest COVID/data-gap handling. That is a credible, decision-useful page.
**Would bounce because:** (1) on the primary mobile viewport the **school name itself is obscured** by the map + compare button (F1), so the first "is this the right school?" glance fails; (2) there is **no way to discover or reach comparable nearby schools** (F2) — the site's whole reason to exist is invisible from its highest-traffic entry point, so a parent leaves to find alternatives elsewhere; (3) on secondary pages the **key numbers are jargon** with no in-place definition (F3/F6), so a non-specialist can't interpret them.
**Highest-leverage fixes:** un-overlap the H1 (F1); add a "Nearby / similar schools — add to compare" module to the detail page (F2); reuse the existing `/compare` metric definitions as in-place tooltips/captions on the detail page (F3/F6).
## Screenshots
- `j2-school-mobile-fold.png`: Castle mobile first screen — H1 crushed between map and "+ Add to Compare".
- `j2-school-mobile-full.png`: full Castle mobile page — section order Ofsted→GCSE→Admissions→History→Wellbeing.
- `j2-school-mobile-gcse.png`: Castle GCSE section — jargon without definitions; "Nat avg 39.1" badge overlapping the chart heading; section nav clipped ("Histo").
- `j2-school-mobile-history.png`: Castle trend chart — national series is an unlabelled grey dashed line; data gap bridged.
- `j2-school-mobile-compare-added.png`: after "Add to Compare" — "✓ In Comparison" + bottom-tab "1" badge, no toast; button still over the H1.
- `j2-compare-oneschool-mobile.png`: `/compare` with one school — where metric definitions (Attainment 8) actually appear.
- `j2-welland-mobile-full.png`: Welland (primary) full page — same H1 overlap; strong in-place KS2 explainers and graceful missing-data handling.
- `j2-school-desktop-fold.png`: Castle desktop fold — clean H1, full section nav, persistent compare tray.
@@ -0,0 +1,125 @@
# Journey 3: Building a comparison (`/compare`) — audit notes
**Pages visited:** `/compare` (empty state, direct entry), `/compare?urns=…` (populated, 13 schools), add-school modal, `/school/142161-…` (persistence round-trip)
**Viewports:** 390×844 (primary), 1440×900 (+720×450 proxy for 200% reflow)
**Site:** live https://schoolcompare.co.uk only. Read-only: searched, added/removed schools in the compare basket (client/localStorage + URL state), opened/closed the modal, hovered the chart. Submitted no data-modifying forms. `selectedSchools` was cleared from localStorage only to reproduce a genuine empty state and to test the share-link defect.
## Task attempt log
**Direct entry premise:** 20% of sessions enter on `/compare`, so the empty state is judged as a cold arrival.
**Mobile (390×844) — empty state.**
- Navigating to `/compare` directly first showed a *persisted* prior selection (The Castle School, carried in `localStorage.selectedSchools` from an earlier session) — the URL self-rehydrated to `?urns=136916&metric=…`. This is persistence working (see Works well) but means the "empty" state is only seen by first-ever visitors. Cleared storage to capture the true empty state (`j3-compare-empty-mobile.png`).
- Empty state is **instructive and actionable**: heading "No schools selected", body "Add schools from the home page or search to start comparing", and a primary button **"+ Add Schools to Compare"** that opens an in-page search modal. A cold arrival can start without leaving the page.
**Mobile — building a two-school comparison. Taps counted from empty state:**
1. Tap **"+ Add Schools to Compare"** → opens modal, auto-focuses the "School name or postcode" search box.
2. **Type** "St Mary" → live result list appears (name + LA + school type + "+ Compare" per row).
3. Tap **"+ Compare"** on a result → school added; **modal stays open, field clears** ("Comparing 1 school" behind it).
4. **Type** "Ottery St Mary" → result list.
5. Tap **"+ Compare"** → second school added (`?urns=142161,113105`).
6. Tap **"Close modal"** → reveals the comparison (`j3-compare-two-schools-mobile.png`).
**6 distinct interactions (4 taps + 2 text-entry sequences)** to a two-school comparison. Critically, both schools had to be **found by name/postcode** — there is no browse / "schools near this one" / "similar schools" on-ramp inside compare (ties to Journey 2 F2). A parent who knows only one school name cannot build a comparison here.
**Mobile — comparison output assessment.**
- Per-metric **school cards stack vertically** and are fully legible; each shows name, LA, type, the selected metric value (e.g. 77.0% vs 51.0%), colour-keyed to the chart. Good on 390px.
- **Chart** ("Performance Over Time"): renders on real scroll (blank in full-page captures = lazy-render artifact, confirmed by element screenshot `j3-compare-chart-mobile.png`). Legend labels each line by **colour + full school name** — colour is not the sole differentiator (WCAG 1.4.1 satisfied). `canvas` has `role="img"` but **`aria-label` = null** (no text alt).
- **"Detailed Comparison" table**: wrapper 626px inside a 324px column → only **Year + the first school column fit**; the second school is off-screen behind horizontal scroll (`overflow-x:auto`, no visible scroll affordance). The whole point of the table (side-by-side) can't be seen at once on the primary viewport.
- Metric selector carries a **plain-English caption** ("% meeting expected standard in reading, writing and maths") — jargon defined in place (the thing school-detail pages lacked, Journey 2 F3).
**Mobile — remove / add / persistence.**
- **Remove (×)** is immediate: no confirm, no toast, no undo; URL updates instantly.
- **Postcode search works**: "TA1 5AU" → The Castle School (advertised feature, functional).
- **Add beyond two / phase mixing**: adding a secondary school to a primary comparison keeps the header at "Comparing 2 schools" but renders **only the primary card**; the secondary sits hidden behind a "Secondary (1)" tab with no explanation.
- **Persistence**: navigated to a school detail page, then back to bare `/compare` — both schools restored and URL rehydrated to `?urns=142161,136916`. State survives real navigation via localStorage; comparisons are URL-encoded / deep-linkable.
**Desktop (1440×900).**
- Table fits with **both/all school columns visible** (no page overflow). Layout clean (`j3-compare-two-schools-desktop.png`).
- **Keyboard add/remove**: "+ Add School" reachable and Enter-activatable; modal **opens with focus moved into the search input**; Tab reaches a result's "+ Compare" with a visible **2px solid orange (#E07256)** focus outline; Enter adds it. **But** after adding, focus **drops to `document.body`**; after **Escape** (which does close the modal) focus is again on `body`, **not** returned to the "+ Add School" trigger.
- Modal has **no `role="dialog"` and no `aria-modal`** — not announced as a dialog.
- **Chart tooltip** (`j3-compare-chart-tooltip-desktop.png`): hover shows year + per-school values with colour swatch + name — **mouse-only** (no touch/keyboard equivalent), but the same values are present as text in the table below.
- **200% zoom (720×450 proxy)**: no page-level horizontal overflow — content reflows (WCAG 1.4.10 pass); the detailed table retains its own contained horizontal scroll.
### Is 32% of exits task-complete satisfaction or abandonment?
**Mixed, with concrete abandonment drivers — it is not safe to read the exits as pure satisfaction.**
- *Points to satisfaction:* compare is a terminal "results" tool — the natural next step (visit/apply) is off-site, so a high exit share is partly expected; persistence + shareable URLs mean some exits are "saved for later"; the core output (cards + chart + table) renders and metrics are explained.
- *Points to abandonment:* (a) **20% ENTER on `/compare`**, often via a shared link — and the **localStorage-overrides-URL defect (F4)** silently shows a returning recipient *their own* schools instead of the shared ones, a confusing dead end; (b) a 2-school comparison needs 6 interactions **and prior knowledge of both school names** (no discovery on-ramp) — a parent with one school hits a wall; (c) on mobile the **detailed table hides the second school off-screen** behind an unaffordanced scroll (F1), so the comparison can look incomplete; (d) low-contrast primary buttons (F6) degrade the mobile-daylight path. F1/F4/F6 are each capable of turning an intended task-complete exit into premature abandonment, especially for the 20% arriving on shared links.
## Friction points
- **F1. "Detailed Comparison" table hides the second/third school off-screen on mobile**
- Evidence: at 390px the table is 626px inside a 324px `.tableWrapper` (`overflow-x:auto`); only Year + the first school column are visible, second column cut off with no scroll affordance (`j3-compare-two-schools-mobile.png`).
- Criterion violated: mobile-usability standard (horizontal scrolling of primary content is a known antipattern) + Nielsen #6 (Recognition rather than recall) — a parent must remember school A's numbers while scrolling to school B; the tool's core "side-by-side" promise is defeated on the primary viewport.
- Argument: the whole reason a parent opens compare is to see schools next to each other; on mobile they can't, and with no scroll cue may believe the second school's data is missing and leave.
- Severity guess: P2 (the stacked metric cards + chart do show both, softening it)
- **F2. Comparison table scroll region is not keyboard-accessible**
- Evidence: axe `scrollable-region-focusable` (serious, 1 node) on `.ComparisonView-module__…tableWrapper`; the overflowing wrapper has no `tabindex`.
- Criterion violated: WCAG 2.2 **2.1.1 Keyboard (A)**.
- Argument: a keyboard-only parent cannot scroll the detailed table to reveal columns beyond the first school — data is literally unreachable without a mouse/touch.
- Severity guess: P2
- **F3. Trend chart exposes no text alternative to assistive tech**
- Evidence: axe `role-img-alt` (serious) on `canvas`; `aria-label` = null on both mobile and desktop.
- Criterion violated: WCAG 2.2 **1.1.1 Non-text Content (A)**.
- Argument: a screen-reader parent gets nothing from "Performance Over Time"; the redundant table softens this but the chart's at-a-glance trend story is lost. Recurs from Journey 2 F5 — systemic to the chart component.
- Severity guess: P2
- **F4. Share link is silently overridden by the visitor's own localStorage**
- Evidence: navigating to `/compare?urns=142161,113105` while `selectedSchools` held a different set resolved the page (and URL) back to the stored `urns=142161,136916`; only after clearing localStorage did the shared URL load its intended schools.
- Criterion violated: Nielsen #1 (Visibility of system status) / consistency; task-flow obstruction of an **advertised primary feature** ("Share" button).
- Argument: a parent shares their shortlist with a partner; if the recipient has ever used compare, they silently see *their own* schools with no error or notice — the shared comparison is unreproducible and the collaboration breaks. This directly hits the 20% who enter on `/compare` via links.
- Severity guess: P1
- **F5. Add-school modal lacks dialog semantics and loses focus on add/close**
- Evidence: modal has no `role="dialog"` and no `aria-modal`; after a keyboard "+ Compare" add, `document.activeElement` = `BODY`; after Escape-close (Escape does dismiss it), focus is again on `BODY`, not the "+ Add School" trigger.
- Criterion violated: WCAG 2.2 **4.1.2 Name, Role, Value (A)** + **2.4.3 Focus Order (A)**.
- Argument: screen-reader users aren't told a dialog opened; keyboard users lose their place after every add (focus jumps to page top) and after closing, so building a multi-school comparison means re-tabbing from the top repeatedly — friction on the add flow every comparison depends on.
- Severity guess: P2 (P1-candidate for keyboard/AT users building 3+ school comparisons)
- **F6. Colour-contrast failures on the primary compare controls**
- Evidence: axe `color-contrast` (serious, 5 nodes populated) — sample targets `.btn-primary` (the "+ Add School" / "+ Compare" buttons), `.…phaseTabActive` (active Primary/Secondary tab), `.…tabActive .tabLabel` (active bottom-nav label).
- Criterion violated: WCAG 2.2 **1.4.3 Contrast (Minimum) (AA)**.
- Argument: the exact orange buttons and active tabs a parent must use to build and read a comparison have insufficient text contrast — hard to read on a phone in daylight (the mobile-primary audience). Recurs site-wide (Journey 1/2).
- Severity guess: P1
- **F7. Mixed-phase selection reports "Comparing 2 schools" while showing one**
- Evidence: with a primary + a secondary selected, header reads "Comparing 2 schools" but only the primary card/chart/table render; the secondary is hidden behind a "Secondary (1)" tab with no explanatory copy.
- Criterion violated: Nielsen #1 (Visibility of system status) & #2 (Match between system and real world).
- Argument: a parent who added two schools sees one and may think the second was dropped; nothing explains the phase split. (Separating KS2 vs GCSE metrics is itself correct — see Works well — only the count/label is misleading.)
- Severity guess: P3
- **F8. Remove is instant with no undo or confirmation**
- Evidence: tapping the × removed a school immediately — no confirm, no toast, no undo; URL updated instantly.
- Criterion violated: Nielsen #3 (User control & freedom — support undo).
- Argument: an accidental tap on the small (28px) × on mobile silently loses a school the parent may have spent a name-search to add; recovery means re-searching. A one-tap "undo" would prevent the loss.
- Severity guess: P3
## Works well — keep
- **Empty state is instructive *and* actionable**: clear "No schools selected" message plus a "+ Add Schools to Compare" button that opens an in-page search modal — the 20% who enter cold on `/compare` can start immediately (answers Journey 2's "no on-ramp" concern *at the compare page itself*).
- **Metric selector carries a plain-English caption** for the chosen metric (e.g. "% meeting expected standard in reading, writing and maths") — jargon defined in place, exactly what school-detail pages omitted.
- **Search accepts name and postcode** (TA1 5AU resolved to the correct school) — the advertised postcode feature works.
- **Efficient multi-add**: the modal stays open and clears the field after each add, so adding several schools needs no reopen.
- **Robust persistence + deep-linking**: selection survives real navigation away and back (localStorage), and comparisons are fully URL-encoded/shareable.
- **Chart accessibility basics done right for sighted users**: legend labels each series by colour **and** full school name (WCAG 1.4.1 satisfied); desktop hover tooltip gives precise per-year values; the table repeats those values as text.
- **Phase separation** (Primary/Secondary tabs) sensibly prevents nonsensical KS2-vs-GCSE metric comparisons.
- **Desktop keyboard + reflow**: visible 2px orange focus outlines on controls; modal opens with focus moved into the search field; Escape dismisses it; all columns visible without overflow; content reflows at 200% zoom with no page-level horizontal scroll (WCAG 1.4.10 pass).
## Axe results
- `/compare` (empty) @ 390×844: **1 violation**`color-contrast` (serious, 2 nodes: active nav tab label, `.btn-primary`).
- `/compare?urns=142161,113105` (populated, 2 schools) @ 390×844: **3 violations**`color-contrast` (serious, 5 nodes: `.btn-primary`, active phase tab, active nav tab), `role-img-alt` (serious, 1 node: `canvas`), `scrollable-region-focusable` (serious, 1 node: `.tableWrapper`).
- Desktop 1440×900 populated: `canvas` `aria-label` still null (role-img-alt persists); `color-contrast` on `.btn-primary`/active tabs persists (viewport-independent).
## Manual WCAG spot checks
- Touch targets ≥24px on interactive elements (WCAG 2.5.8): **pass** — "+ Add School" 139×38, Share 110×40, metric select 324×41, phase tabs ~130×40. Smallest is the **× remove at 28×28** — above the 24px minimum but the tightest target and below the 44px comfortable norm.
- Keyboard: tab order / focus visibility (desktop): **pass with a gap** — logical order and visible 2px orange outlines on controls; modal opens with focus moved into the search box; **fail on focus restoration** — focus drops to `body` after an add and after Escape-close rather than returning to a sensible place (F5).
- Zoom 200% text reflow (desktop, 720×450 proxy): **pass** — no page-level horizontal scroll; only the detailed table retains its contained scroll.
## Screenshots
- `j3-compare-empty-mobile.png`: true empty state (localStorage cleared) — "No schools selected" + "+ Add Schools to Compare".
- `j3-compare-addmodal-mobile.png`: add-school modal with search field.
- `j3-compare-two-schools-mobile.png`: mobile 2-school comparison (cards stack; detailed table shows only first school column).
- `j3-compare-chart-mobile.png`: element capture proving the chart renders with a colour+name legend.
- `j3-compare-two-schools-desktop.png`: desktop 2-school comparison (both columns visible).
- `j3-compare-chart-tooltip-desktop.png`: mouse-hover tooltip with per-school values (3-school legend, colour + label).
@@ -0,0 +1,94 @@
# Journey 4: Rankings → shortlist — audit notes
**Pages visited:** `/rankings`, `/rankings?local_authority=Solihull`, `/rankings?local_authority=Solihull&metric=rwm_expected_pct`, `/school/{urn}` (drill-in + back)
**Viewports:** 390×844, 1440×900
## Task attempt log
Parent scenario: "show me the good schools around here" — filter to a local authority, scan, jump to a school (and ideally shortlist a few to compare).
**Mobile (390×844):**
1. Loaded `/rankings`. The filter controls (Metric / Area / Year) are visible directly above the list — no accordion or hidden panel, so filtering is immediately discoverable (good). Default subtitle: "Top-performing schools by reading, writing & maths combined higher % — showing top 100".
2. Filtered to Solihull: one interaction on the **Area** native select → picked "Solihull". List updated to 54 schools; URL became `?local_authority=Solihull`. Seconds-to-goal: fast, ~1 select interaction.
3. Changed **Metric** to "Reading, Writing & Maths Combined %". Subtitle updated dynamically to "% meeting expected standard in reading, writing and maths"; URL gained `&metric=rwm_expected_pct`. Both filter and metric are encoded in the URL.
4. Drilled into a school by tapping the school-name link; navigated back (browser back). **Filter (Solihull), metric, and vertical scroll position (2500px) were all restored.** This is the strongest result of the journey.
5. Observed that on mobile the **Type** and **Action** columns are `display:none`, and the metric % column sits past the initial fold requiring horizontal scroll of a nested table wrapper.
**Desktop (1440×900):** Full 6-column table (RANK, SCHOOL, AREA, TYPE, metric %, ACTION with View / +Compare). Table is a real `<table>` with `<thead>`/`<th>`. Ran axe, keyboard/focus checks, and a 200%-zoom reflow proxy (720px width).
## Friction points
- **F1. The ranking numbers themselves are low-contrast teal (fails AA)**
- Evidence: `j4-rankings-desktop.png`; axe `color-contrast` (serious) reported **100 nodes** on desktop, sampleTargets include `.valueCell strong` (the teal "63.0%", "60.0%"… values in every row) and the teal "+ Compare" links. Value colour ≈ teal `rgb(45,125,125)` on cream.
- Criterion violated: WCAG 2.2 AA 1.4.3 Contrast (Minimum).
- Argument: The percentage is the entire reason a parent is on this page — it is the score they are comparing schools by. Rendering the primary data in a colour that fails contrast makes the key number hard to read for low-vision parents (and outdoors on a phone). (Low-contrast text also seen on earlier journeys, but here it degrades the core content, not chrome.)
- Severity guess: P1
- **F2. Cannot add a school to the compare shortlist from the ranked list on mobile**
- Evidence: computed style — the `td` in the "Action" column (containing "View" and "+ Compare") is `display:none` at 390px; visible only at desktop width. The bottom nav shows a "Compare (3)" feature, so shortlisting is a first-class task.
- Criterion violated: Task-flow obstruction; Nielsen "User control & freedom" / "Flexibility & efficiency". The journey is literally rankings → shortlist, and the shortlist action is removed on the primary (mobile) viewport.
- Argument: A parent on a phone scanning the good local schools cannot build a comparison set from the rankings — they must open each school page individually and find another route in, adding steps to the core task on the highest-traffic device class.
- Severity guess: P2
- **F3. The ranking value is off-screen on mobile — the list shows names but not scores without horizontal scroll**
- Evidence: `j4-rankings-mobile.png`, `j4-rankings-filtered-mobile.png`. RANK + SCHOOL + AREA fill the 324px table wrapper; the metric % column (e.g. "86.0%") is present but requires horizontally scrolling the nested `.tableWrapper` (scrollWidth 551 > clientWidth 324) to reveal.
- Criterion violated: Mobile usability (primary content not visible in the initial viewport). (Data tables are a recognised 1.4.10 Reflow exception, so noted as usability friction rather than a hard SC failure.)
- Argument: A parent sees a ranked list of names but not the numbers that justify the ranking, unless they discover a sideways swipe inside the table. The comparison value — the point of the page — is hidden by default.
- Severity guess: P2
- **F4. Horizontally-scrollable table wrapper is keyboard-inaccessible and unlabelled**
- Evidence: `.RankingsView…tableWrapper` has `overflow-x:auto` with `scrollWidth > clientWidth`, but no `tabindex="0"`, no `role`, no `aria-label`.
- Criterion violated: WCAG 2.2 A 2.1.1 Keyboard (scrollable region not operable by keyboard); also 1.3.1 (unnamed region for screen readers).
- Argument: Keyboard-only and switch users cannot scroll the table sideways to reach the off-screen metric/Action columns; screen-reader users get no region name. (Keyboard-inaccessible scrollable regions also seen on earlier journeys.)
- Severity guess: P2
- **F5. Default ranking uses unexplained "higher standard" jargon**
- Evidence: default metric is "Reading, Writing & Maths Combined **Higher** %"; top school scores 63.0%. Subtitle says "% achieving higher standard in reading, writing & maths combined" — but "higher standard" (greater depth) itself is not explained, and it is not the more familiar "expected standard".
- Criterion violated: Nielsen "Match between system and the real world" / "Help & documentation".
- Argument: A parent who doesn't know that "higher standard" means the top ~1 in 6 pupils may misread 63% as mediocre, when 63% at the higher standard is exceptional. The default frames every school's headline number in terms the audience is least likely to understand. (The chosen **year** — 2024/25 — is shown in the Year select but not repeated in the results subtitle.)
- Severity guess: P2
- **F6. 33-option metric dropdown is a flat list of dataset jargon**
- Evidence: metric select has 33 options including "GPS Expected %", "Maths Progress", "Reading Average Score", "Disadvantaged Gap", "% EAL Pupils", "% SEN Support", "% Pupil Stability", "GPS Test Absence %" — one ungrouped list, no plain-language expansion of abbreviations.
- Criterion violated: Nielsen "Recognition rather than recall" / "Match with the real world".
- Argument: A parent looking for "the good schools" must wade past acronyms (GPS, EAL, SEN) and technical measures (scaled "Average Score", "Progress") with no grouping or help text to find the metric they actually understand.
- Severity guess: P3
- **F7. No column-sort affordance; `<th>`s lack `scope`**
- Evidence: `<thead>` cells have `cursor:auto`, no `aria-sort`, no button, and are not clickable; no `scope` attribute and no `<caption>`. Ordering is controllable only via the Metric dropdown.
- Criterion violated: Nielsen "Consistency & standards" (users expect a ranked data table to sort by clicking a column header); WCAG 1.3.1 best-practice (missing `scope` on header cells).
- Argument: Parents who click "READING, WRITING & MATHS…" expecting to re-sort get no response; the only re-order path (a separate dropdown) is less discoverable than the convention they expect.
- Severity guess: P3
- **F8. Filter selects remove the focus outline for a subtle border-colour change**
- Evidence: CSS `.filter-select:focus { border-color: var(--accent-teal); outline: none; }`. Links and buttons, by contrast, get a strong 2px orange focus ring (`rgb(224,114,86)`).
- Criterion violated: WCAG 2.2 AA 2.4.11 Focus Appearance (indicator area/contrast); inconsistent focus treatment.
- Argument: A keyboard user tabbing through Metric/Area/Year gets only a faint 1.5px border hue shift — much weaker than the focus ring everywhere else — making it easy to lose track of focus while operating the filters.
- Severity guess: P3
## Works well — keep
- **Back-navigation preserves full state.** After drilling into a school and pressing back, the LA filter, the chosen metric, AND the vertical scroll position were all restored (verified scroll 2500px + `area=Solihull` + `metric=rwm_expected_pct`). State lives in the URL, so results are also shareable/bookmarkable. This is the classic mobile task-killer check and the page passes cleanly.
- **The selected metric is explained in plain language and updates live.** Switching the dropdown changed the subtitle from "% achieving higher standard…" to "% meeting expected standard in reading, writing and maths" — good real-time sensemaking.
- **Filter selects are properly labelled.** Each select has an associated visible `<label>` (Metric / Area / Year). The unlabelled-select (WCAG 4.1.2 select-name) issue seen on the home results state does NOT recur here.
- **Semantic table.** Real `<table>` with `<thead>` and `<th>` header cells — screen readers announce it as a table with columns, not a pile of divs.
- **Skip link + logical tab order.** A "Skip to main content" link is first in the tab order, followed by nav → phase tabs → the three filter selects → table rows, in reading order.
- **Reflow at 200% / narrow width.** At 720px (200% zoom proxy) the filters stack vertically and there is no page-level horizontal scroll.
## Axe results
- `/rankings` @ 390×844 (default): **1** violation — `color-contrast` (serious, 2 nodes: active nav tab + active "Primary (KS2)" phase tab, white on orange).
- `/rankings?local_authority=Solihull` @ 390×844 (filtered): **1** violation — `color-contrast` (serious, 2 nodes: same active tabs).
- `/rankings` @ 1440×900 (default): **1** violation — `color-contrast` (serious, **100 nodes**: active tabs + `.valueCell strong` teal metric numbers across every row, plus teal "+ Compare" links). Node count jumps on desktop because the full 100-row table with visible metric values is rendered.
## Manual WCAG spot checks
- Touch targets ≥24px on interactive elements: **Pass (for visible targets).** School-name link 123×86px, Metric select 324×41px, phase tab 148×40px — all comfortably above 24px. Note: View / +Compare buttons are hidden on mobile (see F2), so no mobile shortlist target exists.
- Keyboard: tab order, focus visibility (desktop): **Mostly pass, one weakness.** Tab order logical (skip-link → nav → tabs → filters → rows); links and buttons show a strong 2px orange focus ring; filter selects suppress the outline for a faint teal border change (see F8).
- Zoom 200% text reflow (desktop): **Pass.** Filters reflow to a single column; no horizontal page scroll at 720px effective width.
## Screenshots
- `j4-rankings-mobile.png`: default `/rankings` at 390×844 — filters visible above list; table shows RANK/SCHOOL/AREA with metric % cut off to the right.
- `j4-rankings-filtered-mobile.png`: `/rankings?local_authority=Solihull` at 390×844 — 54 Solihull schools.
- `j4-rankings-desktop.png`: `/rankings` at 1440×900 — full 6-column table; teal metric values and teal "+ Compare" links visible (low-contrast, F1).
@@ -0,0 +1,55 @@
# Journey 5: Admissions content — audit notes
**Pages visited:** `/` (reverse-discoverability check), `/admissions`
**Viewports:** 390×844 (mobile only — light pass per brief)
## Task attempt log
Goal of this pass: light single-viewport review of `/admissions`, plus the open question — is 5% of page views low because parents don't need admissions content, or because they can't find it?
1. Loaded `/` at 390×844 and inspected the full accessibility snapshot for every path into admissions content. Found three independent paths, all one tap from the homepage: (a) a persistent bottom tab bar with a dedicated "Admissions" tab visible on every page without scrolling, (b) a "Key admissions deadlines" widget on the homepage itself (above the fold on most phones, right below the search box and "Schools near me" button) with a "Full admissions guide →" link, and (c) a footer "Admissions guide" link. No hunting required — this is unusually well-exposed for content that gets only 5% of views.
2. Navigated to `/admissions`. Page loaded a full guide: hero with a live "days until next milestone" countdown strip, a sticky in-page nav ("Primary / Secondary / Tips"), a "Primary school admissions" timeline (6 stages: research criteria → portal opens → deadline → offer day → accept/decline → appeals), an identically structured "Secondary school admissions" timeline, and a "Three things most parents get wrong" section.
3. Checked heading hierarchy via `document.querySelectorAll('h1,h2,h3,h4')`: H1 "School Admissions Guide" → H2 "Primary school admissions" → H2 "Secondary school admissions" → H2 "Three things most parents get wrong" → H3×3 tip headings → (footer landmark) H3 "SchoolCompare" → H4×2. Clean, no skipped levels.
4. Checked line length on `main p` elements via `getBoundingClientRect` + `textContent.length`: body copy renders in a ~240px column at ~14px font, roughly 3032 characters per line. Narrow, but this is a function of the 390px viewport and card padding, not a defect — not flagged as a friction point.
5. Checked the "recently added" SchoolCompare tool cross-links: every one of the 12 timeline steps (6 primary + 6 secondary) ends with a contextual CTA linking to `/` ("Find schools & view their admissions history", "Look up your allocated school"), `/compare` ("Build and compare your shortlist", "Weigh your offer against your other choices"), or `/rankings` ("Compare performance to order your preferences", "Gather performance evidence for your case"). Copy is tailored to the specific stage (e.g. the deadline step links to rankings framed as "order your preferences", not a generic "see rankings"). These read as genuinely contextual, not bolted on.
6. Checked touch target sizes on cross-link CTAs via `getBoundingClientRect`: most are 42px tall, well above minimum, but two variants ("Build and compare your shortlist →" and "Look up your allocated school →") measure only 21px tall.
7. Ran the axe-core snippet (`axe-snippet.js`) verbatim via `browser_evaluate`. 1 violation type, `color-contrast` (serious), 13 nodes, including the active bottom-nav tab label, the "England · Primary & Secondary" eyebrow, and the deadline countdown chip text.
8. Visual cohesion check (screenshot `j5-admissions-mobile.png`, full page): same cream background, coral/teal accent palette, card styling, and typography as the homepage's "Key admissions deadlines" widget. No visible inconsistency with the rest of the site in this pass.
**Discoverability answer:** Not a discoverability problem on the evidence gathered here. Admissions is reachable in 1 tap from any page (bottom tab bar) and is additionally surfaced unprompted on the homepage itself via the deadlines widget and in the footer. If 5% of views is "low," the more likely explanations are demand-side (most visits are one-off school lookups, not admissions research) or a mismatch between when parents need this content (a narrow autumn/winter window) and when they're on the site — not a findability failure.
## Friction points
- **F1. Color-contrast failures on nav label, eyebrow, and deadline chip text**
- Evidence: axe-core `color-contrast` rule, impact `serious`, 13 nodes. Sample targets: `.Navigation-module__Pj2Xoq__tabActive .tabLabel`, `.AdmissionsView-module__HSIWdq__eyebrow`, `.AdmissionsView-module__HSIWdq__chipDeadline .chipTrackDeadline`.
- Criterion violated: WCAG 2.2 SC 1.4.3 Contrast (Minimum), AA.
- Argument: the affected elements include the deadline countdown chips — the single most load-bearing piece of information on this page (parents are here specifically to check "how many days until the deadline"). Low-vision parents reading this on a phone in poor lighting are the exact audience this feature is for.
- Severity guess: P1 (WCAG failure, so not capped at P2; serious axe impact on primary task-critical content).
- **F2. Two cross-link CTA styles fall below the 24px touch-target minimum**
- Evidence: `getBoundingClientRect()` on all `main a` elements — "Build and compare your shortlist →" and "Look up your allocated school →" both measure 21px tall (vs. 42px for the other 4 CTA variants on the same page).
- Criterion violated: WCAG 2.2 SC 2.5.8 Target Size (Minimum), AA (24×24 CSS px, with only a narrow exception for links inline within a sentence — these render as standalone single-line CTAs, not text wrapped mid-paragraph, so the exception is doubtful).
- Argument: these are two of the twelve "recently added" cross-links meant to funnel admissions readers into the Search/Compare/Rankings tools — the exact conversion path this content exists to support. A tap target 3px under spec, inconsistent with sibling CTAs at 42px on the same page, adds avoidable mis-tap friction for a stressed parent thumbing through a countdown page.
- Severity guess: P2 (WCAG failure but a small, inconsistent-styling shortfall rather than a missing target).
## Works well — keep
- Admissions is discoverable in one tap from anywhere (bottom tab bar) and is proactively surfaced on the homepage (deadlines widget) and footer — this rules out "parents can't find it" as the likely explanation for low view share.
- The 12 contextual cross-links from admissions timeline steps into Search/Compare/Rankings are well-targeted to the specific stage of the journey (e.g., "Compare performance to order your preferences" at the deadline step, not a generic link) — a good example of tool cross-linking done with intent rather than bolted on.
- Heading hierarchy is clean (H1→H2→H3, no skipped levels), which matters for screen-reader users navigating a long timeline page by heading.
- Visual style is consistent with the rest of the site (palette, card treatment, typography match the homepage) — no cohesion break found in this pass.
## Axe results
- `/admissions` @ 390×844: 1 violation — `color-contrast` (serious, 13 nodes)
## Manual WCAG spot checks
- Touch targets ≥24px on interactive elements: fail — 2 of the 12 cross-link CTAs measure 21px tall (see F2); bottom-nav tabs (56px) and sticky in-page nav links (35px) pass.
- Keyboard: tab order, focus visibility (desktop only): not checked — mobile-only pass per brief.
- Zoom 200% text reflow (desktop only): not checked — mobile-only pass per brief.
## Screenshots
- `j5-home-mobile-top.png`: homepage above the fold at 390×844, showing the bottom tab bar with an "Admissions" tab and the "Key admissions deadlines" widget with "Full admissions guide →" link — evidence for the discoverability finding.
- `j5-admissions-mobile.png`: full-page screenshot of `/admissions` at 390×844, used for the cohesion and layout review.
@@ -0,0 +1,215 @@
# SchoolCompare UX/UI Audit — 2026-07-02
## Method summary
Journey-led walk-through of the **live site** (schoolcompare.co.uk) via Playwright browser tools, at two viewports: **390×844 mobile (primary — 56% of traffic)** and **1440×900 desktop** (720×450 used as the 200%-zoom reflow proxy). Five journeys in traffic order — home→find-school (63% of entries), cold landing on a school page (SEO long tail), building a comparison (27% of views), rankings→shortlist (12%), admissions (5%, light pass) — followed by a cross-cutting cohesion pass comparing computed typography, colour, and component styles across all six page types, verified against `nextjs-app` source to distinguish "token exists but bypassed" from "no token exists".
Every page state visited was scanned with **axe-core 4.10.2** (WCAG 2.2 A/AA rule set) plus manual checks: touch targets ≥24px (2.5.8), keyboard order and focus visibility, and 200% reflow. A finding is included only if it cites a Nielsen heuristic, a WCAG 2.2 AA failure, a mobile-usability standard, or an observed task-flow obstruction — no taste-only findings. Priorities are traffic-weighted using the 30-day analytics baseline: entries `/` 63% / `/compare` 20% / `/rankings` 6%; exits `/` 46% / `/compare` 32% / `/rankings` 13%; 56% mobile. Uplift bands are honest small/moderate/large indications — there is no funnel instrumentation to support percentages.
Full evidence trail: `docs/superpowers/specs/2026-07-02-ux-audit-notes/` (six notes files; screenshots referenced by filename live in the session scratchpad, not committed).
## What works today — keep
These are strengths the fixes below must not regress:
- **Name search is genuinely excellent.** "Welland Primary" → the correct school as sole top result, in 3 taps with instant loads — comfortably under the 15s target. Result cards are information-rich (Ofsted grade+year, RWM % with trend arrow, "+12 pts vs national", pupil count, LA) and adapt correctly to phase (Attainment 8 for secondary, RWM for primary).
- **Full-postcode proximity search works, and works well** *(verified on 2026-07-02 recheck)* — typing a full postcode (e.g. `B91 3DL`) into the home search box yields "13 schools within 1.0 miles of B91 3DL", distance on every card, nearest-first ordering, a 0.55 mile radius selector, and a List/Map toggle; the "Schools near me" geolocation button offers the same without typing. Only partial input degrades (see P2.0).
- **Search is the unmistakable primary action** — above the fold on both viewports, 70px tap target, plain-English placeholder, content order puts the task first.
- **The primary (KS2) school page explains its data in plain English** — "End-of-primary-school tests taken by Year 6 pupils", the in-place "Why is combined lower?" explainer, legends on subject charts. This is the model the secondary template should copy.
- **Everything is anchored to the national average** ("+14 pts", "National avg 39.1") — a non-specialist can judge good/bad without leaving the page.
- **Graceful, honest missing-data handling** — COVID-cancellation notes, "contact the admissions authority" for absent cut-offs; no broken or empty UI anywhere data is missing.
- **Ofsted sections are clear, current, and sourced** — grade + inspection date, the post-Sept-2024 grading-change note, link to the real Ofsted report.
- **Compare's empty state is instructive and actionable** — "No schools selected" + "+ Add Schools to Compare" opening an in-page search modal; the 20% who enter cold can start immediately. The modal supports efficient multi-add (stays open, clears field) and accepts postcodes.
- **State persistence is strong across the site** — compare selections survive navigation (localStorage) and are URL-encoded/deep-linkable; rankings restores filter, metric, *and* scroll position on back-navigation (the classic mobile task-killer check, passed cleanly).
- **Compare's metric selector carries a plain-English caption** — jargon defined at point of use; the chart legend labels series by colour *and* name (WCAG 1.4.1 satisfied).
- **Accessibility fundamentals are partly in place** — working skip link, logical tab order everywhere, strong 2px orange focus outlines on links/buttons, real `<table>` semantics on rankings, properly labelled rankings filters, clean heading hierarchy on admissions, no horizontal scroll at 200% reflow on any page tested.
- **Admissions content is well-integrated** — reachable in one tap from anywhere, surfaced proactively on the homepage, with 12 stage-tailored cross-links into Search/Compare/Rankings that read as genuinely contextual.
- **Cohesion anchors:** header/footer pixel-identical on all six pages; coral=primary / teal=secondary held without role collisions; rankings and compare share one phase-tab treatment; the deadline chip is byte-for-byte reused between home and admissions.
## Findings
### P0 — Urgent
- **P0.1 — Colour-contrast failures on every main route, including primary data and primary CTAs** *(merges J1-F4, J2-F4, J3-F6, J4-F1, J5-F1)*
- Evidence: axe `color-contrast` (serious) on **all five routes**: home 6 nodes (active nav tab, `.btn`, how-it-works text) + 3 on results (`.btn-primary`, Ofsted badge); school template **13 nodes** (Back button, "View on map", active metric tab); compare 5 nodes (`.btn-primary`, active phase/nav tabs); rankings **100 nodes on desktop** — the teal metric values (`.valueCell strong`) in every row plus "+ Compare" links; admissions 13 nodes including the **deadline countdown chips**. Screenshots `j4-rankings-desktop.png`, `j5-admissions-mobile.png`.
- Criterion: WCAG 2.2 **1.4.3 Contrast (Minimum), AA** — failing on every main page.
- Argument: this is not chrome-only. On rankings the failing text is the ranking score itself — the entire reason a parent is on the page; on admissions it is the days-until-deadline chip — the page's most load-bearing fact; on home/compare it is the primary CTA styling. The mobile-primary audience reads this outdoors on phones, where low contrast bites everyone, not just low-vision users.
- Recommendation: adjust the failing token values once (`--accent-coral` on light text pairings, the teal-on-cream value colour, tint-background label colours) and re-run axe on all five routes. This is a token-level fix, not per-page work.
- Uplift: **accessibility compliance — large improvement** (clears the single biggest violation class, present on 100% of audited pages); secondary small positive effect on all engagement metrics via legibility for the 56% mobile share.
- **P0.2 — School name (H1) is obscured by the map and the "+ Add to Compare" button on mobile — systemic to the school template** *(J2-F1)*
- Evidence: measured bounding boxes on both audited schools — Castle: map bottom 280px / H1 top 272px / compare-button top 286px / H1 bottom 309px; Welland: same pattern — leaving a ~6px legible sliver of the name. Screenshots `j2-school-mobile-fold.png`, `j2-welland-mobile-full.png`. Desktop is unaffected.
- Criterion: Nielsen #1 (visibility of system status) and #8; task-flow obstruction of cold-landing orientation; defeats the intent of WCAG 1.4.10 (content obscured at the primary viewport).
- Argument: school pages are the SEO landing template. A parent arriving from Google asks one question first — "is this the school I searched for?" — and the element that answers it is illegible on the majority viewport. This is the exact first-screen failure that produces an immediate back-to-Google bounce, on the template whose entire job is converting search traffic.
- Recommendation: restack the mobile hero so map, H1, and compare button occupy non-overlapping space (e.g. compare button below the identity block or docked, map height capped above the H1). One template, both school-detail components (see P2.6).
- Uplift: **school-page bounce-back-to-Google — decrease, moderate** (fixes the first-glance orientation on every SEO landing; the page content behind it is already strong, so retention should follow).
### P1 — High
- **P1.1 — Filter and Sort selects on home results have no accessible name** *(J1-F3)*
- Evidence: axe `select-name` (impact **critical**, 2 nodes) on `/?search=…` — the phase filter and sort selects. (Borderline P0 by the "clear WCAG failure on a main page" rule; held at P1 because it affects one page state and two controls.)
- Criterion: WCAG 2.2 **4.1.2 Name, Role, Value, Level A**.
- Argument: these are the controls a parent uses to narrow a mixed result set to "Primary" — a screen-reader user hears two anonymous comboboxes on the main journey's results view. Rankings labels its identical selects correctly, so this is an omission, not a pattern.
- Recommendation: associate visible `<label>`s (or `aria-label`) exactly as `/rankings` already does.
- Uplift: **accessibility compliance — moderate** (removes the only *critical*-impact violation found); minutes of effort.
- **P1.2 — School pages offer no "nearby / similar schools" path — the compare value proposition has no on-ramp** *(J2-F2, corroborated by J3 tap-count)*
- Evidence: full-page snapshots of both school pages show no nearby/similar module (sections end Wellbeing → footer). Building a real 2-school comparison from a school page requires Compare → "+ Add School" → **manual name search** — 6 interactions total, and the parent must already know the competitor's name (`j3-compare-two-schools-mobile.png`, `j2-compare-oneschool-mobile.png`).
- Criterion: Nielsen #7 (flexibility & efficiency); task-flow obstruction — the site's differentiator is unreachable from its SEO entry template without prior knowledge.
- Argument: a parent cold-landing on one school has maximum comparison intent and zero alternatives on screen. The site sends them back to Google to discover the other local schools — Google then keeps them.
- Recommendation: add a "Schools nearby" module to the detail template (nearest same-phase schools by lat/long — data already exists) with per-row "+ Compare"; the same list can power an in-modal "nearby" tab on `/compare`.
- Uplift: **school-page bounce-back-to-Google — decrease, moderate**, and **compare 32% exit — decrease, small-to-moderate** (creates the missing bridge between the two biggest content surfaces).
- **P1.3 — Shared compare links are silently overridden by the recipient's own localStorage** *(J3-F4)*
- Evidence: navigating to `/compare?urns=142161,113105` with a different stored selection resolved the page and URL back to the stored set; the shared schools never appeared. Reproduced; only clearing localStorage restored the link's intent.
- Criterion: Nielsen #1 (visibility of system status); task-flow obstruction of an advertised primary feature (the Share button).
- Argument: 20% of sessions *enter* on `/compare`, many via shared links. A parent shares a shortlist with a partner; if the partner has ever used compare, they silently see their own old schools with no notice — the collaboration breaks and neither party knows why.
- Recommendation: explicit URL params must win over localStorage (persist *after* honouring the URL); optionally prompt "Replace your saved comparison with the shared one?".
- Uplift: **compare 32% exit rate — decrease, small-to-moderate** (repairs a confusing dead end at a high-intent entry point; band limited by the shared-link share of entries being unmeasured).
- **P1.4 — Trend/results charts expose no text alternative to assistive tech** *(merges J2-F5, J3-F3)*
- Evidence: axe `role-img-alt` (serious) on the `<canvas>` on both school pages and populated `/compare`; `aria-label` is null at both viewports.
- Criterion: WCAG 2.2 **1.1.1 Non-text Content, Level A** — on the two main content templates.
- Argument: the site's key visualisation — performance over time — announces nothing to a screen-reader parent. Compare's data table partially mitigates; the school-page trend chart has only a collapsed raw-data expander.
- Recommendation: generate a descriptive `aria-label` per chart ("Attainment 8, 20192025: school 53.4 vs national 39.1, school above national every year") — the data is already in the component; one shared chart wrapper fixes all instances.
- Uplift: **accessibility compliance — moderate** (clears a Level-A class on the highest-traffic templates).
- **P1.5 — Horizontally-scrollable regions are keyboard-inaccessible site-wide** *(merges J1-F5(a), J3-F2, J4-F4)*
- Evidence: axe `scrollable-region-focusable` (serious) on the home countdown rail, the compare detailed-table wrapper, and (manually confirmed, same pattern) the rankings table wrapper — `overflow-x: auto` with no `tabindex="0"`, no role/label.
- Criterion: WCAG 2.2 **2.1.1 Keyboard, Level A**.
- Argument: keyboard-only users literally cannot reach the off-screen columns — which on compare and rankings contain the actual comparison data (see P2.1). Three instances of one missing pattern.
- Recommendation: `tabindex="0"` + `role="region"` + `aria-label` on every overflow wrapper; one shared wrapper component prevents recurrence.
- Uplift: **accessibility compliance — moderate**; unlocks the data for keyboard users wherever P2.1's layout still requires scrolling.
- **P1.6 — Search offers no autocomplete/typeahead** *(J1-F1)*
- Evidence: typing "Welland Primary" (both viewports) produced no suggestions at any keystroke — raw input until submit. The compare modal's live-results search proves the capability exists in the codebase.
- Criterion: Nielsen #6 (recognition rather than recall); NN-g site-search guidance; error prevention.
- Argument: parents rarely know a school's exact registered name ("Welland" vs "Welland Church of England…"). With no suggestions, a misspelling means a zero-result dead end on the 63%-of-entries path, and every search costs full typing plus a blind submit.
- Recommendation: reuse the compare modal's live-search behaviour on the home search box (debounced suggestions: school name + LA, and a "search this postcode" row for postcode-shaped input).
- Uplift: **home 46% exit rate — decrease, moderate** and **share of sessions reaching a school page — increase, moderate** (assists the majority entry path at its first interaction).
- **P1.7 — The mobile hero omits the value proposition entirely** *(J1-F6)*
- Evidence: desktop shows the "UPDATED WITH 2026/2027 ADMISSIONS RESULTS" trust badge and the "24,000+ schools… side by side, in one place" subheading; mobile renders only the poetic H1 ("Every school in England, *compared.*") and a bare search box (`j1-home-desktop-fold.png` vs `j1-home-mobile-fold.png`).
- Criterion: mobile content parity; Nielsen #1 — first-visit orientation ("what is this, why trust it") absent on the primary viewport.
- Argument: 63% of entries land here and 56% of traffic is mobile; a first-time visitor gets no statement of coverage, data source, or freshness above the fold. Weak value proposition at first glance is a classic bounce driver and plausibly a material slice of the 46% exit rate.
- Recommendation: restore a compact version of the badge + one-line value prop under the mobile H1 (one text block; the fold has room above the deadline rail).
- Uplift: **home 46% exit rate — decrease, small-to-moderate** (copy-only change aimed squarely at first-impression exits).
### P2 — Medium
- **P2.0 — Partial or malformed postcode input silently falls back to text search with no distances** *(J1-F2, corrected on 2026-07-02 recheck — the original P0 claim that postcode search lacks proximity was wrong: a full postcode works, see "What works today")*
- Evidence: the search submit handler routes input through a strict full-postcode regex (`FilterBar.tsx` `isValidPostcode()`). `B91 3DL``/?postcode=…&radius=1`: "13 schools within 1.0 miles", distance on every card, nearest-first, radius selector, map toggle (`j1-fullpostcode-results-mobile.png`). But `B91 3` (an outcode+sector a parent plausibly types) → `/?search=B91+3`: text-matched, relevance-ordered, distance-less, mixed-phase results led by a secondary school (`j1-postcode-results-mobile.png`) — with no notice that a different, better mode exists.
- Criterion: Nielsen #1 (visibility of system status — silent mode switch) and #9 (help users recognise and recover); error tolerance for the "or postcode" promise in the placeholder.
- Argument: the two result pages look near-identical, so a parent who types "B91" or fat-fingers a character never learns that proximity search exists; they just get an apparently arbitrary list on the highest-traffic path.
- Recommendation: detect postcode-*shaped* input that fails full validation (outcode/sector patterns, near-miss typos) and either geocode it anyway (postcodes.io supports outcodes) or show an inline nudge — "Enter a full postcode like B91 3DL to see schools by distance."
- Uplift: **home 46% exit rate — decrease, small-to-moderate** (recovers the postcode-search subset who type partial input; the full-postcode path already works, which caps the band).
- **P2.1 — Mobile data tables hide the decision-critical columns behind unaffordanced horizontal scroll** *(merges J3-F1, J4-F3)*
- Evidence: compare's "Detailed Comparison" table is 626px inside a 324px wrapper — only Year + the first school's column visible, no scroll cue (`j3-compare-two-schools-mobile.png`); rankings shows RANK/SCHOOL/AREA but the metric % (the ranking's justification) sits off-screen (scrollWidth 551 > 324) (`j4-rankings-mobile.png`).
- Criterion: mobile-usability standard (primary content hidden off-viewport); Nielsen #6 — the user must memorise school A's numbers while scrolling to school B.
- Argument: on compare the side-by-side promise is defeated on the majority viewport (a parent may believe the second school's data is missing); on rankings the list shows names without the scores that rank them. Softened by compare's stacked metric cards, which do show both schools — hence P2 not P1.
- Recommendation: on ≤390px, prioritise the metric column next to the school name (rankings) and use a per-metric stacked layout or sticky first column + visible scroll affordance (compare).
- Uplift: **compare 32% exit — decrease, small**; **rankings→school click-through — increase, small**.
- **P2.2 — Rankings on mobile removes the View/+Compare actions** *(J4-F2)*
- Evidence: the Action column `td` computes `display:none` at 390px; shortlisting from rankings is desktop-only.
- Criterion: task-flow obstruction (the journey is rankings → *shortlist*); Nielsen #7.
- Argument: a parent on a phone scanning the good local schools cannot build a comparison set from the list — each school needs a page visit and a different route in, on the majority device class.
- Recommendation: keep a compact "+" compare affordance per row on mobile (the bottom-tab badge already communicates basket state).
- Uplift: **rankings→school click-through / compare usage — increase, small-to-moderate** (restores the journey's intended endpoint on mobile).
- **P2.3 — Metric jargon unexplained at the point of first contact** *(merges J2-F3, J4-F5)*
- Evidence: secondary school pages show "Attainment 8 score 53.4", "EBacc average point score 4.72" with zero definitions/tooltips (the plain-English definition exists — but only on `/compare`); rankings *defaults* to "higher standard" (greater depth) without explaining it, so the top school's headline reads "63.0%" in the terms parents least understand (`j2-school-mobile-gcse.png`).
- Criterion: Nielsen #2 and #10.
- Argument: a non-specialist cannot judge whether 53.4 or 4.72 is good; a parent may misread 63% at higher standard as mediocre when it is exceptional. The site owns the explanations and withholds them at first contact. The primary KS2 page proves in-place explanation works.
- Recommendation: surface the existing `/compare` metric definitions on school pages (caption or expander beside each figure); default rankings to "expected standard" with "higher standard" as an explained option.
- Uplift: **school-page bounce-back-to-Google — decrease, small-to-moderate** (comprehension is the retention lever for the secondary template).
- **P2.4 — Add-school modal lacks dialog semantics and drops focus** *(J3-F5)*
- Evidence: no `role="dialog"`/`aria-modal`; after a keyboard add, focus lands on `document.body`; after Escape-close, focus is on `body`, not the trigger.
- Criterion: WCAG 2.2 **4.1.2 (A)** + **2.4.3 Focus Order (A)**.
- Argument: screen-reader users aren't told a dialog opened; keyboard users re-tab from the page top after every add — compounding friction on the flow every comparison depends on.
- Recommendation: dialog role + focus trap + focus restoration to the trigger on close; keep focus in the search field after an add (the multi-add UX is otherwise good).
- Uplift: **accessibility compliance — moderate**.
- **P2.5 — School-page sticky section nav clips its last items on mobile** *(J2-F7)*
- Evidence: at 390px, "History" and "Wellbeing" render off-screen; the 448px nav row is clipped, not scrollable (document scrollWidth stays 390) (`j2-school-mobile-gcse.png`).
- Criterion: mobile usability — interactive content unreachable in-viewport; Nielsen #7.
- Argument: 2 of 6 jump links are dead weight on the primary viewport; parents hunting SEN/Wellbeing data must scroll-hunt instead.
- Recommendation: make the nav row horizontally scrollable with an overflow affordance (fade/chevron), or wrap to two lines.
- Uplift: **school-page bounce-back-to-Google — decrease, small**.
- **P2.6 — Two parallel school-detail components have drifted; a duplicate `.btn` rule makes button geometry unpredictable** *(merges cohesion F1, F2)*
- Evidence: `SchoolDetailView` vs `SecondarySchoolDetailView` hardcode divergent values for the same controls (`.btnAdd` radius 8/pad 12×20 vs radius 6/pad 8×16; badge radii 4 vs 3; tab padding differs). Separately `globals.css` defines `.btn` **twice** (lines 151 and 1558) with `.btn-sm` declared between them — source order makes the second definition override `.btn-sm`, so "small" buttons render at 12×24px padding (live-verified on rankings).
- Criterion: Nielsen #4 (consistency & standards); maintenance hazard.
- Argument: parents moving between primary and secondary school pages (the compare flow mixes phases) meet the same controls at subtly different sizes/corners; and any future button edit has a 50/50 chance of landing in the dead rule.
- Recommendation: dedupe `.btn` to one definition (restoring `.btn-sm`); converge both school-detail modules on shared tokens (`--radius-md`), ideally one shared component.
- Uplift: **school-page bounce-back-to-Google — decrease, small** (visual consistency is a trust signal on the template that must convert cold traffic); also removes a recurring source of future drift.
- **P2.7 — Design tokens exist but are bypassed system-wide** *(merges cohesion F3, F4, F5)*
- Evidence: **15 distinct raw radius values** in module CSS against a 4-token scale (no pill token for the 21× `999px` uses); **four different H1 sizes** across five pages (36/44/48/52px), each a separate hardcoded `clamp()`; the "switch view/phase" control has **three different treatments** (rounded segmented on home, square tabs on rankings/compare, underline strip on admissions).
- Criterion: Nielsen #4.
- Argument: corner rounding, title scale, and segmented controls are the primary "one system" cues; the drift reads as many hands and erodes the data-source credibility the site trades on. (Rankings↔compare already share one tab treatment — the model to extend.)
- Recommendation: token-enforcement pass (map raw radii to tokens; add `--radius-pill`), create hero/section title tokens, consolidate the segmented control on the rankings/compare pattern.
- Uplift: **home 46% exit rate — decrease, small** (a visibly coherent system reads as a credible data source at first glance); primarily protects future velocity.
- **P2.8 — Two admissions cross-link CTA variants are below the 24px touch-target minimum** *(J5-F2)*
- Evidence: "Build and compare your shortlist →" and "Look up your allocated school →" measure **21px** tall vs 42px for the other four CTA variants on the same page (getBoundingClientRect).
- Criterion: WCAG 2.2 **2.5.8 Target Size (Minimum), AA** (rendered as standalone CTAs, so the inline-text exception is doubtful).
- Argument: these are the conversion path the admissions content exists to support; a 3px-under-spec target inconsistent with siblings adds mis-tap friction for a stressed parent. (Admissions-only, but a WCAG failure, so it stays P2 per the prioritisation rules.)
- Recommendation: apply the 42px CTA style to all twelve cross-links.
- Uplift: **accessibility compliance — small**.
### P3 — Nice-to-have
- **P3.1 — Mixed-phase selection reports "Comparing 2 schools" while rendering one** *(J3-F7)* — the second school hides behind a "Secondary (1)" tab with no explanation (Nielsen #1/#2). Add one line of copy ("Shown separately — KS2 and GCSE metrics differ"). Uplift: compare exit, small.
- **P3.2 — Compare remove (×) is instant with no undo** *(J3-F8)* — accidental tap on the 28px target silently loses a searched-for school (Nielsen #3). Add a brief undo toast. Uplift: compare exit, small.
- **P3.3 — "Add to Compare" gives no toast/live-region confirmation** *(J2-F8)* — feedback is only a button relabel in the (currently overlapped) hero zone plus a small tab badge; no `role=status` fires (Nielsen #1). Largely mitigated once P0.2 lands. Uplift: compare usage, small.
- **P3.4 — Inconsistent focus indicators on form controls** *(merges J1 search-input note, J4-F8)* — filter selects suppress the outline for a faint 1.5px border hue shift; the home search input uses a 0.12-alpha ring — both far weaker than the site's standard 2px orange outline (WCAG 2.4.11 borderline). Apply the standard outline. Uplift: accessibility compliance, small.
- **P3.5 — Rankings table headers aren't sortable and lack `scope`** *(J4-F7)* — users expect ranked tables to sort on header click (Nielsen #4); `<th>` cells lack `scope`/`aria-sort` (WCAG 1.3.1 best practice). Uplift: rankings→school CTR, small.
- **P3.6 — 33-option metric dropdown is a flat jargon list** *(J4-F6)* — GPS/EAL/SEN abbreviations, ungrouped (Nielsen #6). Group by theme (Results / Progress / Cohort) with plain-language labels. Uplift: rankings→school CTR, small.
- **P3.7 — Home deadline rail defaults its scroll to the least-urgent card** *(J1-F5(b))* — the nearest deadline (121 days) hides behind less urgent ones (Nielsen #1). Order/scroll to soonest-first. Uplift: home exit, small.
- **P3.8 — School trend chart has no legend for the national series; badge overlaps a heading** *(J2-F6)* — the grey dashed line is unexplained (the primary page's charts *do* carry legends — inconsistent); "Nat avg 39.1" overlaps the section heading. Uplift: school-page bounce, small.
- **P3.9 — Residual colour/type token drift** *(cohesion F6, F7, F8)* — pressed-coral exists as three hexes (`#e07256`/`#c45a3f`/`#d4654a`), an off-token gold `#b8920e` on the school-page cross-link badge, H3s render in body font while H2s are Playfair, and school-detail tables use an 11/13px type scale vs 12/15px on rankings/compare. Fold into the P2.7 token pass. Uplift: school-page bounce-back-to-Google, decrease, small.
## Accessibility summary
Axe-core 4.10.2, WCAG 2.2 A/AA rule set. Desktop serves the same DOM/CSS, so mobile results apply at both viewports unless noted.
| Page / state | Viewport | Violations | Detail |
|---|---|---|---|
| `/` (initial) | 390×844 | 2 | `color-contrast` (serious, 6 nodes); `scrollable-region-focusable` (serious, 1) |
| `/?search=Welland+Primary` | 390×844 | 2 | `color-contrast` (serious, 3); **`select-name` (critical, 2)** |
| `/school/…castle` (secondary) | both | 2 | `color-contrast` (serious, 13); `role-img-alt` (serious, 1) |
| `/school/…welland` (primary) | 390×844 | 1 | `role-img-alt` (serious, 1) |
| `/compare` (empty) | 390×844 | 1 | `color-contrast` (serious, 2) |
| `/compare?urns=…` (2 schools) | 390×844 | 3 | `color-contrast` (serious, 5); `role-img-alt` (serious, 1); `scrollable-region-focusable` (serious, 1) |
| `/rankings` (default & filtered) | 390×844 | 1 | `color-contrast` (serious, 2) |
| `/rankings` | 1440×900 | 1 | `color-contrast` (serious, **100 nodes** — metric values in every row) |
| `/admissions` | 390×844 | 1 | `color-contrast` (serious, 13 — incl. deadline chips) |
**Manual checks:** Touch targets ≥24px — pass on all core paths (smallest: compare × at 28px, Back at 64×28) **except** two admissions CTAs at 21px (P2.8). Keyboard — order logical everywhere with a strong 2px outline on links/buttons; gaps: unlabelled home-results selects (P1.1), modal focus loss (P2.4), suppressed outlines on filter selects and the faint search-input ring (P3.4), keyboard-unreachable scroll regions (P1.5). 200% reflow — **pass on every page tested** (no horizontal scroll at 720px; tables retain contained scroll, a recognised 1.4.10 exception).
**Overall WCAG 2.2 AA posture:** not currently conformant. Four violation classes account for everything axe found — contrast (every route), missing chart text alternatives (Level A), keyboard-inaccessible scroll regions (Level A), unlabelled selects (Level A, critical) — plus manual findings on target size, focus appearance, and dialog semantics. All are pattern-level fixes; none require redesign. The foundations (skip links, tab order, semantics, reflow, labels on rankings) are solid, which is why the fix list is short relative to the violation node counts.
## Suggested implementation sequence
Batches ordered by uplift-per-effort; each is a plausible standalone follow-up project.
**Batch 1 — WCAG compliance sweep (small effort, large compliance uplift).**
P0.1 contrast token fixes; P1.1 label the two selects; P1.4 chart `aria-label`s via one shared wrapper; P1.5 `tabindex`/role on the three scroll wrappers; P2.4 dialog semantics + focus restoration; P2.8 42px CTAs on admissions; P3.4 standard focus outlines. Mostly CSS-variable and attribute changes; re-run the axe harness on all five routes as the acceptance check. Clears every axe violation class found.
**Batch 2 — School-template mobile fold (small effort, direct SEO-retention uplift).**
P0.2 un-overlap the H1/map/compare button; P2.5 scrollable section nav; P3.8 trend-chart legend + badge overlap; P3.3 add-confirmation live region. One template, all SEO landings.
**Batch 3 — Home search assistance (small-moderate effort, home-funnel lever).**
P1.6 autocomplete on the home search (reuse the compare modal's live search); P2.0 graceful handling of partial/malformed postcodes (geocode outcodes or nudge toward a full postcode — the full-postcode proximity search already works); P1.7 mobile value-prop line. Together these target the 46% home exit rate from three directions.
**Batch 4 — Discovery and sharing bridges (moderate effort, compare-funnel uplift).**
P1.2 "Schools nearby" module on school pages (feeds compare); P1.3 URL-over-localStorage precedence for shared links; P2.2 mobile +Compare on rankings rows; P3.1/P3.2 compare state-clarity nits.
**Batch 5 — Comprehension pass (small-moderate effort).**
P2.3 reuse `/compare` metric definitions on school pages and default rankings to "expected standard"; P2.1 mobile table layouts (sticky first column / stacked metrics); P3.5/P3.6 rankings sort affordance + grouped metric picker; P3.7 deadline-rail ordering.
**Batch 6 — Cohesion & token enforcement (housekeeping, protects velocity).**
P2.6 dedupe `.btn`, converge the two school-detail components; P2.7 radius/title/segmented-control tokenisation; P3.9 residual colour/type drift. Best done after Batches 12 so the new token values land once.
@@ -0,0 +1,215 @@
# Exam Results Taxonomy — Phase Grouping and Sixth-Form Separation
**Date:** 2026-07-07
**Status:** Approved design (taxonomy/analysis only — no implementation in this doc's scope)
## Purpose
Classify every exam-result metric SchoolCompare displays today into four phase
groups — **Primary**, **Secondary**, **Sixth form**, **Other** — and define an
authoritative rule for separating schools that have a sixth form from those
that don't. This document is the reference for:
1. How the UI should group results sections and rankings by phase.
2. The future KS5 (A-level) ingestion work — the Sixth form group lists the
concrete DfE metrics as placeholders with source columns.
3. Replacing the fragile `age_range contains "18"` heuristic with the GIAS
`OfficialSixthForm` flag.
## 1. Grouping principle
Metrics are grouped by **the key stage of the assessment**, not by the phase
of the school displaying them. An all-through school (418) shows metrics in
all three exam groups; a pure primary shows only the Primary group.
| Group | Assessments | Key stage | Taken at age | Data status |
|---|---|---|---|---|
| **Primary** | KS2 SATs (reading, writing TA, maths, GPS, science TA) | KS2 | 1011 (Year 6) | ✅ Live — `marts.fact_ks2_performance` |
| **Secondary** | GCSEs, Attainment 8 / Progress 8, EBacc | KS4 | 1516 (Year 11) | ✅ Live — `marts.fact_ks4_performance` |
| **Sixth form** | A levels, applied general, tech levels | KS5 (1618) | 1718 (Year 1213) | ⏳ Not ingested — placeholders in §4 |
| **Other** | Non-exam context displayed alongside results | n/a | n/a | ✅ Live — various marts |
Not covered (not displayed today, candidates for future "Other"/Primary):
EYFS Good Level of Development, Year 1 Phonics check, Year 4 Multiplication
Tables Check, KS1 assessments (no longer published at school level by DfE).
## 2. Metric-by-metric mapping (current site)
Every key in `backend/schemas.py` `METRIC_DEFINITIONS` — the single source of
truth for what the site displays — mapped to its phase group. `category` is
the existing schema category; source columns are the DfE names used at
ingestion (legacy performance-tables CSV for KS2, EES for KS4).
### Primary (KS2 SATs)
| Metric key | Category | DfE source column |
|---|---|---|
| `rwm_expected_pct` | expected | `PTRWM_EXP` |
| `reading_expected_pct` | expected | `PTREAD_EXP` |
| `writing_expected_pct` | expected | `PTWRITTA_EXP` |
| `maths_expected_pct` | expected | `PTMAT_EXP` |
| `gps_expected_pct` | expected | `PTGPS_EXP` |
| `science_expected_pct` | expected | `PTSCITA_EXP` |
| `rwm_high_pct` | higher | `PTRWM_HIGH` |
| `reading_high_pct` | higher | `PTREAD_HIGH` |
| `writing_high_pct` | higher | `PTWRITTA_HIGH` |
| `maths_high_pct` | higher | `PTMAT_HIGH` |
| `gps_high_pct` | higher | `PTGPS_HIGH` |
| `reading_progress` | progress | `READPROG` |
| `writing_progress` | progress | `WRITPROG` |
| `maths_progress` | progress | `MATPROG` |
| `reading_avg_score` | average | `READ_AVERAGE` |
| `maths_avg_score` | average | `MAT_AVERAGE` |
| `gps_avg_score` | average | `GPS_AVERAGE` |
| `rwm_expected_boys_pct` | gender | `PTRWM_EXP_B` |
| `rwm_expected_girls_pct` | gender | `PTRWM_EXP_G` |
| `rwm_high_boys_pct` | gender | `PTRWM_HIGH_B` |
| `rwm_high_girls_pct` | gender | `PTRWM_HIGH_G` |
| `rwm_expected_disadvantaged_pct` | equity | `PTRWM_EXP_FSM6CLA1A` |
| `rwm_expected_non_disadvantaged_pct` | equity | `PTRWM_EXP_NotFSM6CLA1A` |
| `disadvantaged_gap` | equity | `DIFFN_RWM_EXP` |
| `reading_absence_pct` | absence | `PTREAD_AT` |
| `gps_absence_pct` | absence | `PTGPS_AT` |
| `maths_absence_pct` | absence | `PTMAT_AT` |
| `writing_absence_pct` | absence | `PTWRITTA_AD` |
| `science_absence_pct` | absence | `PTSCITA_AD` |
| `rwm_expected_3yr_pct` | trends | `PTRWM_EXP_3YR` |
| `reading_avg_3yr` | trends | `READ_AVERAGE_3YR` |
| `maths_avg_3yr` | trends | `MAT_AVERAGE_3YR` |
The absence metrics measure absence *from KS2 tests*, so they belong to
Primary even though they are not attainment scores. National comparators for
this group come from `marts.fact_ks2_national_averages`.
### Secondary (KS4 / GCSE)
| Metric key | Category | EES source column |
|---|---|---|
| `attainment_8_score` | gcse | `attainment8_average` |
| `progress_8_score` | gcse | `progress8_average` |
| `english_maths_standard_pass_pct` | gcse | `engmath_94_percent` |
| `english_maths_strong_pass_pct` | gcse | `engmath_95_percent` |
| `ebacc_entry_pct` | gcse | `ebacc_entering_percent` |
| `ebacc_standard_pass_pct` | gcse | `ebacc_94_percent` |
| `ebacc_strong_pass_pct` | gcse | `ebacc_95_percent` |
| `ebacc_avg_score` | gcse | `ebacc_aps_average` |
| `gcse_grade_91_pct` | gcse | `gcse_91_percent` |
Also stored in `marts.fact_ks4_performance` (and `fact_performance`) but not
yet in `METRIC_DEFINITIONS` — Secondary group members when surfaced:
`progress_8_lower_ci`, `progress_8_upper_ci`, `progress_8_english`,
`progress_8_maths`, `progress_8_ebacc`, `progress_8_open`,
`prior_attainment_avg` (KS2 baseline of the GCSE cohort), `sen_pct`.
### Sixth form (KS5)
No metrics today. The secondary school detail view renders a static note
("Post-16 destination data coming soon") when the school has a sixth form.
Placeholders for ingestion are specified in §4.
### Other (non-exam context)
Displayed alongside results but not tied to any assessment:
| Metric key / surface | Category | Source |
|---|---|---|
| `disadvantaged_pct` | context | KS2 CSV `PTFSM6CLA1A` |
| `eal_pct` | context | KS2 CSV `PTEALGRP2` |
| `sen_support_pct` | context | KS2 CSV `PSENELK` (KS4 fallback `sen_no_ehcp_pupil_percent`) |
| `stability_pct` | context | KS2 CSV `PTMOBN` |
| Ofsted grades incl. `sixth_form_provision` / `rc_sixth_form` | — | `marts.fact_ofsted_inspection` |
| Admissions (offers, oversubscription) | — | `marts.fact_admissions` |
| Finance (per-pupil spend, cost shares) | — | `marts.fact_finance` |
| Deprivation (IDACI) | — | `marts.fact_deprivation` |
| Pupil characteristics (census) | — | `marts.fact_pupil_characteristics` |
Note: the context metrics are cohort characteristics of the KS2 cohort at
source, but they are presented (and should stay presented) as school-level
context, so they group as Other, not Primary.
## 3. Sixth-form separation
### Definition (authoritative)
> A school **has a sixth form** iff GIAS `OfficialSixthForm (name)` =
> `"Has a sixth form"` for its URN.
GIAS values are `Has a sixth form`, `Does not have a sixth form`, and
`Not applicable` / blank. `Not applicable` (nurseries, primaries, PRUs) maps
to **false**. This field is the DfE's registry flag, updated continuously,
and is the only source that correctly classifies:
- 1619 sixth-form colleges and UTCs (age ranges like `14-19`, `16-19` that
the current substring heuristic misclassifies as *no* sixth form);
- schools whose statutory age range extends to 18 on paper but which have no
registered post-16 provision.
### Pipeline change (implemented 2026-07-07)
1. `stg_gias_establishments.sql`: add
`"OfficialSixthForm (name)" as official_sixth_form`.
2. `dim_school.sql` (+ `models.py` `DimSchool`, `_marts_schema.yml`): add
`has_sixth_form boolean` = `official_sixth_form = 'Has a sixth form'`.
3. Expose `has_sixth_form` on the school API payloads.
Implemented in `feat/gias-sixth-form-flag` — see
`docs/superpowers/plans/2026-07-07-gias-sixth-form-flag.md`.
### Current heuristic — audit of `age_range` ~ "18" sites
All must migrate to the `has_sixth_form` flag once exposed:
| Site | Current behaviour |
|---|---|
| `backend/app.py:419-422` | `/api/schools?has_sixth_form=yes\|no` filters on `age_range.str.contains("18")` |
| `nextjs-app/components/SecondarySchoolDetailView.tsx:101` | "Sixth form" badge + coming-soon note from `age_range?.includes('18')` |
| `nextjs-app/components/FilterBar.tsx:370-372` | Filter labels hard-code "(11-18)" / "(11-16)" — labels should drop the age-range parenthetical since sixth form ≠ age range |
Fallback rule: if GIAS is blank for a URN (rare; new establishments), fall
back to the age-range heuristic and log the URN.
### UI separation rules
- **School page**: schools with `has_sixth_form = true` show a Sixth form
results section (placeholder until KS5 data lands); schools without never
show it. Badge on the header as today, but driven by the flag.
- **Search/rankings filter**: "With sixth form" / "Without sixth form" uses
the flag; applies to secondary and all-through phases.
- **Comparison**: when comparing a with-sixth-form school against one
without, the Sixth form group renders "No sixth form" for the latter
rather than blank cells, making the structural difference explicit.
## 4. Sixth form placeholders — future KS5 ingestion spec
Source: DfE "A level and other 16 to 18 results" (EES, preferred — matches
the KS4 EES tap) or legacy performance-tables `england_ks5final.csv`.
Column names below are from the legacy KS5 CSV; verify against the EES
release chosen at ingestion time.
| Proposed metric key | Name | Legacy source column | Type |
|---|---|---|---|
| `alevel_aps_per_entry` | A level average points per entry | `TALLPPE_ALEV_1618` | score |
| `alevel_avg_grade` | A level average grade (e.g. B-) | `TALLPPEGRD_ALEV_1618` | grade |
| `academic_aps_per_entry` | Academic qualifications APS per entry | `TALLPPE_ACAD_1618` | score |
| `applied_general_aps_per_entry` | Applied general APS per entry | `TALLPPE_AGEN_1618` | score |
| `tech_level_aps_per_entry` | Tech level APS per entry | `TALLPPE_TLEV_1618` | score |
| `english_progress_1618` | English progress (1618, unfinished GCSE 4+) | `PROGENG_1618` | score |
| `maths_progress_1618` | Maths progress (1618) | `PROGMAT_1618` | score |
| `ks5_cohort_size` | Students at end of 1618 study | `TALLPUP_1618` | count |
| `alevel_3plus_aab_pct` | % achieving AAB+ in ≥2 facilitating subjects | `TAAB2FAC_1618` | percentage |
| `ks5_retention_pct` | Retention (completed main programme) | study-programme retention measure | percentage |
| `ks5_destinations_pct` | Sustained education/employment destination | 1618 destination measures dataset | percentage |
Proposed landing shape mirrors KS4: `stg_ees_ks5.sql`
`int_ks5_with_lineage.sql``marts.fact_ks5_performance` (one row per URN
per year), joined into `fact_performance`, with a `category: "sixth_form"`
(or `"alevel"`) block added to `METRIC_DEFINITIONS`.
## 5. Out of scope
- Any implementation (pipeline, API, or UI changes) — this is the taxonomy
reference; implementation work items are §3 "Pipeline change", the
heuristic migration audit, and §4 ingestion, each to be planned separately.
- Middle schools (deemed secondary/primary): they follow the assessment-based
grouping automatically — no special casing.
- Independent schools: no DfE performance data published; unaffected.
@@ -0,0 +1,182 @@
# GIAS Code Dictionaries — Codes in Marts, Names in Code
**Date:** 2026-07-09
**Status:** Implemented 2026-07-09 — see docs/superpowers/plans/2026-07-09-gias-code-dictionaries.md
## Goal
Six GIAS classification fields are stored in the marts as repeated name
strings. Replace them with the official DfE integer codes and translate
code → name in application code. After this change the marts carry only
codes for:
| GIAS field | Today (marts, string) | After (marts, int) |
|---|---|---|
| `TypeOfEstablishment (name)` | `dim_school.school_type` | `school_type_code` |
| `EstablishmentStatus (name)` | `dim_school.status` | `status_code` |
| `PhaseOfEducation (name)` | `dim_school.phase` | `phase_code` |
| `OfficialSixthForm (name)` | (already reduced to `has_sixth_form` bool) | `official_sixth_form_code` in staging only; mart keeps the bool |
| `ReligiousCharacter (name)` | `dim_school.religious_character` | `religious_character_code` |
| `AdmissionsPolicy (name)` | `dim_school.admissions_policy` | `admissions_policy_code` |
Motivation: smaller marts and stable enum values for filtering. (Honest
sizing note: at ~25k open schools the raw performance win is modest; the
durable benefits are storage, DfE-governed vocabulary, and filter values
that can't drift with GIAS renames.)
## Decisions (made during brainstorming)
1. **GIAS native codes**, not custom enums. The GIAS bulk CSV publishes an
official `X (code)` column beside every `X (name)` column. We ingest the
DfE's own codes; no invented mapping to maintain.
2. **Translation lives in the backend at the API boundary.** The API keeps
serving today's name strings; the frontend, e2e journeys, and API
consumers are untouched.
## Design
### 1. Tap (Singer schema)
Add the six `(code)` columns to `GIASEstablishmentsStream.schema` in
`pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py`:
```
"TypeOfEstablishment (code)", "EstablishmentStatus (code)",
"PhaseOfEducation (code)", "OfficialSixthForm (code)",
"ReligiousCharacter (code)", "AdmissionsPolicy (code)"
```
The `(name)` columns **stay declared** — raw keeps both so we can detect
dictionary drift (§4) and regenerate dictionaries from live data.
### 2. Staging (`stg_gias_establishments.sql`)
- Add int casts: `school_type_code`, `status_code`, `phase_code`,
`official_sixth_form_code`, `religious_character_code`,
`admissions_policy_code` (all `cast(nullif(trim(...), '') as integer)`).
- Remove the corresponding name columns from the staging select
(`school_type`, `status`, `phase`, `official_sixth_form`,
`religious_character`, `admissions_policy`). Names live only in raw.
### 3. Marts
**`dim_school`** stores codes only:
- `school_type_code`, `status_code`, `phase_code`,
`religious_character_code`, `admissions_policy_code` replace their
string columns.
- Status filter becomes `where status_code in (<open>, <proposed-to-close>)`.
The numeric values are read from live raw data at implementation time
(`select distinct "EstablishmentStatus (code)", "EstablishmentStatus (name)"`),
never assumed from memory. Same filter in `dim_location`.
- `has_sixth_form` derives from `official_sixth_form_code`
(`<has-code>` → true, `<does-not>/<not-applicable>` → false, null →
`statutory_high_age >= 18` fallback). The `lower(trim(...))` string guard
becomes obsolete and is removed.
- `phase_code` derivation keeps today's cascade but emits codes:
1. GIAS `phase_code` when it is a real value (not the not-applicable code);
2. statutory-age inference emits the matching GIAS code
(Primary / Secondary / All-through — numeric values confirmed from
live data at implementation);
3. school-name heuristics (unchanged — they match `school_name`, which is
not one of the six fields) emit the same codes;
4. else null.
- dbt schema tests: `accepted_values` (severity **warn**) on every code
column, values taken from the dictionary; `not_null` warn on `phase_code`
(mirrors today's phase test); `has_sixth_form` tests unchanged.
**`dim_location`**: only the status filter changes (must stay byte-identical
to `dim_school`'s — the API inner-joins the two).
### 4. Dictionaries
**Canonical module: `backend/gias_codes.py`**
```python
ESTABLISHMENT_STATUS: dict[int, str]
SCHOOL_TYPE: dict[int, str]
PHASE_OF_EDUCATION: dict[int, str]
OFFICIAL_SIXTH_FORM: dict[int, str]
RELIGIOUS_CHARACTER: dict[int, str]
ADMISSIONS_POLICY: dict[int, str]
def translate(code: int | None, mapping: dict[int, str]) -> str | None:
"""None -> None; unknown code -> 'Unknown (<code>)' + warning log."""
```
- Contents are generated from live raw data
(`SELECT DISTINCT code, name FROM raw.gias_establishments ...` per field)
and sanity-checked against the DfE GIAS registers. Names must be
byte-identical to what the API serves today.
- Unknown codes never blank the UI: `translate` returns `"Unknown (<code>)"`
and logs, so a new DfE value degrades gracefully.
**Pipeline copy: `pipeline/scripts/gias_codes.py`**
The app and pipeline Docker images have disjoint build contexts
(`Dockerfile` copies `backend/`; `pipeline/Dockerfile` copies `pipeline/`),
so the Typesense sync cannot import the backend module. It gets a
byte-identical copy, and a backend unit test asserts
`backend/gias_codes.py` and `pipeline/scripts/gias_codes.py` have identical
content — drift fails CI. (Deliberately chosen over codegen: six dicts do
not justify build machinery.)
**Seed for drift detection: `pipeline/transform/seeds/gias_code_names.csv`**
Columns `field,code,name` mirroring the dictionary. A dbt test (severity
warn) compares live raw `(code, name)` pairs against the seed; when DfE adds
or renames a value the nightly run warns, prompting a dictionary + seed
update in one PR.
### 5. Backend translation (API contract unchanged)
- `_MAIN_QUERY` selects the code columns instead of the name columns.
- `load_school_data_as_dataframe()` translates immediately after
`pd.read_sql`, writing today's column names:
```python
df["phase"] = df["phase_code"].map(...)
df["school_type"] = df["school_type_code"].map(...) # then normalize_school_type as today
df["status"] = df["status_code"].map(...)
df["religious_denomination"] = df["religious_character_code"].map(...)
df["admissions_policy"] = df["admissions_policy_code"].map(...)
```
Everything downstream — `PHASE_GROUPS`, filters, payload builders,
`/api/filters`, frontend, e2e — sees exactly today's strings. No frontend
changes.
- `backend/models.py` `DimSchool`: string columns replaced by
`*_code = Column(Integer)`.
### 6. Typesense sync
`pipeline/scripts/sync_typesense.py` selects `phase`, `school_type`,
`religious_character` today. It switches to the code columns and translates
via `pipeline/scripts/gias_codes.py` before indexing, so facet values in
search are unchanged.
### 7. Rollout
- No DB migration: marts are full-rebuild tables.
- Deploy window: until the first post-merge pipeline run, the old marts
still carry string columns while the new backend queries code columns, so
the backend's query fails and it serves empty data (the one-column retry
built for `has_sixth_form` doesn't generalise to six columns, and a full
old-schema fallback query isn't worth it). **Decision: accept the window
and close it operationally — the runbook is merge → deploy → trigger
`school_data_daily` immediately.** The DAG's final step already calls
`/api/admin/reload`, so the backend recovers without a restart.
- Tests: backend unit tests for `translate()` (known / unknown / None),
payload tests asserting names still served, the file-parity test, dbt
schema/seed tests. Frontend: no changes; existing Jest suite is the
regression net.
## Out of scope
- Recoding other string columns (`gender`, `urban_rural`,
`nursery_provision`, `local_authority_name` …) — same pattern can follow
later if this proves out.
- Collapsing academy subtypes (today's `normalize_school_type`) — kept
as-is, applied after translation.
- Serving codes through the API — the contract deliberately keeps names.
+3
View File
@@ -0,0 +1,3 @@
node_modules/
test-results/
playwright-report/
+78
View File
@@ -0,0 +1,78 @@
{
"name": "schoolcompare-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "schoolcompare-e2e",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.49.0"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "schoolcompare-e2e",
"version": "1.0.0",
"private": true,
"description": "Journey tests run against staging as the production promotion gate",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.49.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60_000,
retries: 1,
workers: 2,
reporter: process.env.CI ? 'list' : 'html',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'retain-on-failure',
},
});
+215
View File
@@ -0,0 +1,215 @@
import { test, expect, Page } from '@playwright/test';
/**
* Journey tests for SchoolCompare, run against the staging environment as the
* gate before promotion to production. They assert stable data invariants
* (results exist, key UI renders) rather than exact numbers, so routine data
* refreshes don't break the pipeline.
*/
async function searchByName(page: Page, query: string) {
await page.goto('/');
const searchInput = page.getByPlaceholder('School name or postcode').first();
await searchInput.fill(query);
await searchInput.press('Enter');
await page.waitForURL(/search=|postcode=/);
}
function schoolLinks(page: Page) {
return page.locator('a[href^="/school/"]');
}
test('home page loads with hero search', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1').first()).toBeVisible();
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
});
test('home hero offers a "use my location" shortcut beside the search box', async ({ page }) => {
await page.goto('/');
// The geolocation shortcut lives inside the hero search card, right under the
// search input — not in a separate strip further down the page.
const searchInput = page.getByPlaceholder('School name or postcode').first();
await expect(searchInput).toBeVisible();
const nearMe = page.getByRole('button', { name: /use my location/i });
await expect(nearMe).toBeVisible();
});
test('searching by name returns school results', async ({ page }) => {
await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
expect(await schoolLinks(page).count()).toBeGreaterThan(1);
});
test('searching by postcode returns nearby schools', async ({ page }) => {
await searchByName(page, 'B1 1BB');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
});
test('school detail page renders name and performance data', async ({ page }) => {
await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first();
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
await firstSchool.click();
await page.waitForURL(/\/school\//);
await expect(page.locator('h1').first()).toBeVisible();
// The detail page renders at least one *visible* chart canvas. Plain
// .first() is wrong here: the admissions card stacks its year/trend views
// in one grid cell and keeps the inactive view's canvas visibility:hidden
// by design, and that canvas comes first in the DOM.
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
});
test('school with no performance data still gets a working detail page', async ({ page }) => {
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
// serialization — which the frontend rendered as a 404 on every such SEO
// landing page. Find one via the search API (year === null marks "no
// performance rows") and assert its page renders.
const candidates: number[] = [];
for (const q of ['post 16', 'specialist college', 'sixth form']) {
const resp = await page.request.get(
`/api/schools?search=${encodeURIComponent(q)}&per_page=20`
);
if (!resp.ok()) continue;
const body = await resp.json();
for (const s of body.schools ?? []) {
if (s.year === null && s.urn) candidates.push(s.urn);
}
if (candidates.length) break;
}
test.skip(candidates.length === 0, 'no results-less school in this dataset');
const detail = await page.request.get(`/api/schools/${candidates[0]}`);
expect(detail.status(), 'detail API must not 500 for a results-less school').toBe(200);
await page.goto(`/school/${candidates[0]}`);
await page.waitForURL(/\/school\/\d+-/); // redirected to canonical slug
await expect(page.locator('h1').first()).toBeVisible();
});
test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => {
// iOS Safari has no Element.requestFullscreen; the map must fall back to a
// CSS overlay. Simulate that by removing the API before any page script runs.
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
// @ts-expect-error deliberate API removal
delete Element.prototype.requestFullscreen;
});
await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first();
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
await firstSchool.click();
await page.waitForURL(/\/school\//);
const openMap = page.getByRole('button', { name: 'Open full map' });
await expect(openMap).toBeVisible({ timeout: 15_000 });
await openMap.click();
const closeMap = page.getByRole('button', { name: 'Close map' });
await expect(closeMap).toBeVisible();
await closeMap.click();
await expect(openMap).toBeVisible();
});
test('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
// Same iOS gap as the hero map: no Element.requestFullscreen, so the results
// map's fullscreen button must fall back to a CSS overlay.
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
// @ts-expect-error deliberate API removal
delete Element.prototype.requestFullscreen;
});
await searchByName(page, 'B1 1BB');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
// Switch to the map view, then open the map fullscreen.
await page.getByRole('button', { name: 'Map', exact: true }).click();
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
await expect(openFs).toBeVisible({ timeout: 15_000 });
await openFs.click();
// The button flips to its exit state once the overlay is up.
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
await expect(exitFs).toBeVisible();
await exitFs.click();
await expect(openFs).toBeVisible();
});
test('comparing two schools shows 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);
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
// 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();
});
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await searchByName(page, 'primary');
await 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))];
// Compare three schools, not two: a "primary" search can return all-through
// schools that classify as secondary, and the chips only appear for the
// active phase. With three schools across two phases, the auto-selected
// majority phase always holds ≥2, so the chip legend is guaranteed to render.
expect(urns.length).toBeGreaterThanOrEqual(3);
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${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.
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
const chips = chipGroup.getByRole('button');
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
expect(await chips.count()).toBeGreaterThanOrEqual(2);
// Tapping a chip focuses that school's line; tapping again releases it.
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'true');
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
});
test('rankings page loads a populated table', async ({ page }) => {
await page.goto('/rankings');
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5);
});
test('rankings stay populated after picking a specific year', async ({ page }) => {
// Years are academic-year codes (e.g. 201819); the API must accept them
// as the `year` query param rather than rejecting with a 422.
await page.goto('/rankings');
const yearSelect = page.locator('#year-select');
await expect(yearSelect).toBeVisible({ timeout: 15_000 });
// Pick the last option — the most recent explicit year. The default view
// already proved this year has rows, so an empty table after selecting it
// can only mean the year param was rejected. (The oldest year is no good
// here: staging doesn't always carry the full data history.)
const yearValue = await yearSelect.locator('option').last().getAttribute('value');
expect(yearValue).toBeTruthy();
await yearSelect.selectOption(yearValue!);
await page.waitForURL(/year=/);
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5);
});
-2530
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#1a1612"/>
<circle cx="20" cy="20" r="14" stroke="#e07256" stroke-width="2"/>
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="#e07256" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="20" cy="20" r="3" fill="#e07256"/>
</svg>

Before

Width:  |  Height:  |  Size: 374 B

-663
View File
@@ -1,663 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SchoolCompare | Compare Primary School Performance</title>
<!-- Primary Meta Tags -->
<meta
name="description"
content="Compare primary school KS2 performance across England. Search, filter and compare Reading, Writing and Maths results for thousands of schools."
/>
<meta
name="keywords"
content="school comparison, KS2 results, primary school performance, England schools, SATs results"
/>
<meta name="author" content="SchoolCompare" />
<meta name="robots" content="index, follow" />
<!-- Analytics -->
<script
defer
src="https://analytics.schoolcompare.co.uk/script.js"
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
></script>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Canonical -->
<link rel="canonical" href="https://schoolcompare.co.uk/" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://schoolcompare.co.uk/" />
<meta
property="og:title"
content="SchoolCompare | Compare Primary School Performance"
/>
<meta
property="og:description"
content="Compare primary school KS2 performance across England. Search and compare Reading, Writing and Maths results."
/>
<meta property="og:site_name" content="SchoolCompare" />
<!-- Twitter -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:url" content="https://schoolcompare.co.uk/" />
<meta
name="twitter:title"
content="SchoolCompare | Compare Primary School Performance"
/>
<meta
name="twitter:description"
content="Compare primary school KS2 performance across England."
/>
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "SchoolCompare",
"url": "https://schoolcompare.co.uk",
"description": "Compare primary school KS2 performance across England",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Web",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "GBP"
},
"author": {
"@type": "Organization",
"name": "SchoolCompare",
"url": "https://schoolcompare.co.uk"
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Leaflet Map Library -->
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="noise-overlay"></div>
<header class="header">
<div class="header-content">
<a href="/" class="logo">
<div class="logo-icon">
<svg
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="20"
cy="20"
r="18"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<circle cx="20" cy="20" r="4" fill="currentColor" />
</svg>
</div>
<div class="logo-text">
<span class="logo-title">SchoolCompare</span>
<span class="logo-subtitle">schoolcompare.co.uk</span>
</div>
</a>
<nav class="nav">
<a href="/" class="nav-link active" data-view="home"
>Home</a
>
<a href="/compare" class="nav-link" data-view="compare"
>Compare</a
>
<a href="/rankings" class="nav-link" data-view="rankings"
>Rankings</a
>
</nav>
</div>
</header>
<main class="main">
<!-- Home View -->
<section id="home-view" class="view active">
<div class="hero">
<h1 class="hero-title">
Compare Primary School Performance
</h1>
<p class="hero-subtitle">
Search and compare KS2 results across England's primary
schools
</p>
</div>
<div class="search-section">
<div class="search-mode-toggle">
<button class="search-mode-btn active" data-mode="name">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
Find by Name
</button>
<button class="search-mode-btn" data-mode="location">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
/>
<circle cx="12" cy="10" r="3" />
</svg>
Find by Location
</button>
</div>
<div id="name-search-panel" class="search-panel active">
<div class="search-container">
<input
type="text"
id="school-search"
class="search-input"
placeholder="Search primary schools by name..."
/>
<div class="search-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</div>
</div>
<div class="filter-row">
<select
id="local-authority-filter"
class="filter-select"
>
<option value="">All Areas</option>
</select>
<select id="type-filter" class="filter-select">
<option value="">All School Types</option>
</select>
</div>
</div>
<div id="location-search-panel" class="search-panel">
<div class="location-input-group">
<input
type="text"
id="postcode-search"
class="search-input postcode-input"
placeholder="Enter postcode..."
/>
<select
id="radius-select"
class="filter-select radius-select"
>
<option value="0.5" selected>1/2 mile</option>
<option value="1">1 mile</option>
<option value="2">2 miles</option>
</select>
<select
id="type-filter-location"
class="filter-select"
>
<option value="">All School Types</option>
</select>
<button
id="location-search-btn"
class="btn btn-primary location-btn"
>
Find Nearby
</button>
</div>
</div>
</div>
<div class="view-toggle" id="view-toggle" style="display: none">
<button class="view-toggle-btn active" data-view="list">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
List
</button>
<button class="view-toggle-btn" data-view="map">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
/>
<circle cx="12" cy="10" r="3" />
</svg>
Map
</button>
</div>
<div class="results-container" id="results-container">
<div class="results-map" id="results-map"></div>
<div class="schools-grid" id="schools-grid">
<!-- School cards populated by JS -->
</div>
</div>
</section>
<!-- Compare View -->
<section id="compare-view" class="view">
<div class="compare-header">
<h2 class="section-title">Compare Primary Schools</h2>
<p class="section-subtitle">
Select schools to compare their KS2 performance over
time
</p>
</div>
<div class="compare-search-section">
<input
type="text"
id="compare-search"
class="search-input"
placeholder="Add a school to compare..."
/>
<div id="compare-results" class="compare-results"></div>
</div>
<div class="selected-schools" id="selected-schools">
<div class="empty-selection">
<div class="empty-icon">
<svg
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect
x="6"
y="10"
width="36"
height="28"
rx="2"
/>
<path d="M6 18h36" />
<circle
cx="14"
cy="14"
r="2"
fill="currentColor"
/>
<circle
cx="22"
cy="14"
r="2"
fill="currentColor"
/>
</svg>
</div>
<p>Search and add schools to compare</p>
</div>
</div>
<div
class="charts-section"
id="charts-section"
style="display: none"
>
<div class="metric-selector">
<label>Select KS2 Metric:</label>
<select id="metric-select" class="filter-select">
<optgroup label="Expected Standard">
<option value="rwm_expected_pct">
Reading, Writing & Maths Combined %
</option>
<option value="reading_expected_pct">
Reading Expected %
</option>
<option value="writing_expected_pct">
Writing Expected %
</option>
<option value="maths_expected_pct">
Maths Expected %
</option>
<option value="gps_expected_pct">
GPS Expected %
</option>
<option value="science_expected_pct">
Science Expected %
</option>
</optgroup>
<optgroup label="Higher Standard">
<option value="rwm_high_pct">
RWM Combined Higher %
</option>
<option value="reading_high_pct">
Reading Higher %
</option>
<option value="writing_high_pct">
Writing Higher %
</option>
<option value="maths_high_pct">
Maths Higher %
</option>
<option value="gps_high_pct">
GPS Higher %
</option>
</optgroup>
<optgroup label="Progress Scores">
<option value="reading_progress">
Reading Progress
</option>
<option value="writing_progress">
Writing Progress
</option>
<option value="maths_progress">
Maths Progress
</option>
</optgroup>
<optgroup label="Average Scores">
<option value="reading_avg_score">
Reading Avg Score
</option>
<option value="maths_avg_score">
Maths Avg Score
</option>
<option value="gps_avg_score">
GPS Avg Score
</option>
</optgroup>
<optgroup label="Gender Performance">
<option value="rwm_expected_boys_pct">
RWM Expected % (Boys)
</option>
<option value="rwm_expected_girls_pct">
RWM Expected % (Girls)
</option>
<option value="rwm_high_boys_pct">
RWM Higher % (Boys)
</option>
<option value="rwm_high_girls_pct">
RWM Higher % (Girls)
</option>
</optgroup>
<optgroup label="Equity (Disadvantaged)">
<option value="rwm_expected_disadvantaged_pct">
RWM Expected % (Disadvantaged)
</option>
<option
value="rwm_expected_non_disadvantaged_pct"
>
RWM Expected % (Non-Disadvantaged)
</option>
<option value="disadvantaged_gap">
Disadvantaged Gap vs National
</option>
</optgroup>
<optgroup label="School Context">
<option value="disadvantaged_pct">
% Disadvantaged Pupils
</option>
<option value="eal_pct">% EAL Pupils</option>
<option value="sen_support_pct">
% SEN Support
</option>
<option value="stability_pct">
% Pupil Stability
</option>
</optgroup>
<optgroup label="3-Year Trends">
<option value="rwm_expected_3yr_pct">
RWM Expected % (3-Year Avg)
</option>
<option value="reading_avg_3yr">
Reading Score (3-Year Avg)
</option>
<option value="maths_avg_3yr">
Maths Score (3-Year Avg)
</option>
</optgroup>
</select>
</div>
<div class="chart-container">
<canvas id="comparison-chart"></canvas>
</div>
<div class="data-table-container">
<table class="data-table" id="comparison-table">
<thead>
<tr id="table-header"></tr>
</thead>
<tbody id="table-body"></tbody>
</table>
</div>
</div>
</section>
<!-- Rankings View -->
<section id="rankings-view" class="view">
<div class="rankings-header">
<h2 class="section-title">Primary School Rankings</h2>
<p class="section-subtitle">
Top performing primary schools ranked by KS2 metric
</p>
</div>
<div class="rankings-controls">
<select id="ranking-area" class="filter-select">
<option value="">All Areas</option>
<!-- Populated by JS -->
</select>
<select id="ranking-metric" class="filter-select">
<optgroup label="Expected Standard">
<option value="rwm_expected_pct">
Reading, Writing & Maths Combined %
</option>
<option value="reading_expected_pct">
Reading Expected %
</option>
<option value="writing_expected_pct">
Writing Expected %
</option>
<option value="maths_expected_pct">
Maths Expected %
</option>
<option value="gps_expected_pct">
GPS Expected %
</option>
<option value="science_expected_pct">
Science Expected %
</option>
</optgroup>
<optgroup label="Higher Standard">
<option value="rwm_high_pct">
RWM Combined Higher %
</option>
<option value="reading_high_pct">
Reading Higher %
</option>
<option value="writing_high_pct">
Writing Higher %
</option>
<option value="maths_high_pct">
Maths Higher %
</option>
<option value="gps_high_pct">GPS Higher %</option>
</optgroup>
<optgroup label="Progress Scores">
<option value="reading_progress">
Reading Progress
</option>
<option value="writing_progress">
Writing Progress
</option>
<option value="maths_progress">
Maths Progress
</option>
</optgroup>
<optgroup label="Average Scores">
<option value="reading_avg_score">
Reading Avg Score
</option>
<option value="maths_avg_score">
Maths Avg Score
</option>
<option value="gps_avg_score">GPS Avg Score</option>
</optgroup>
<optgroup label="Gender Performance">
<option value="rwm_expected_boys_pct">
RWM Expected % (Boys)
</option>
<option value="rwm_expected_girls_pct">
RWM Expected % (Girls)
</option>
<option value="rwm_high_boys_pct">
RWM Higher % (Boys)
</option>
<option value="rwm_high_girls_pct">
RWM Higher % (Girls)
</option>
</optgroup>
<optgroup label="Equity (Disadvantaged)">
<option value="rwm_expected_disadvantaged_pct">
RWM Expected % (Disadvantaged)
</option>
<option value="rwm_expected_non_disadvantaged_pct">
RWM Expected % (Non-Disadvantaged)
</option>
</optgroup>
<optgroup label="3-Year Trends">
<option value="rwm_expected_3yr_pct">
RWM Expected % (3-Year Avg)
</option>
</optgroup>
</select>
<select id="ranking-year" class="filter-select">
<!-- Populated by JS -->
</select>
</div>
<div class="rankings-list" id="rankings-list">
<!-- Rankings populated by JS -->
</div>
</section>
</main>
<!-- School Detail Modal -->
<div class="modal" id="school-modal">
<div class="modal-backdrop"></div>
<div class="modal-content">
<button class="modal-close" id="modal-close">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<div class="modal-header">
<button
class="btn btn-primary modal-compare-btn"
id="add-to-compare"
>
Add to Compare
</button>
<h2 id="modal-school-name"></h2>
<div class="modal-meta" id="modal-meta"></div>
<div class="modal-details" id="modal-details"></div>
</div>
<div class="modal-body">
<div class="modal-chart-container">
<canvas id="school-detail-chart"></canvas>
</div>
<div class="modal-stats" id="modal-stats"></div>
<div class="modal-map-container" id="modal-map-container">
<h4>Location</h4>
<div class="modal-map" id="modal-map"></div>
</div>
</div>
</div>
</div>
<footer class="footer">
<div class="footer-content">
<div class="footer-contact">
<a href="mailto:contact@schoolcompare.co.uk">Contact Us</a>
</div>
<div class="footer-source">
<p>
Data source:
<a
href="https://www.compare-school-performance.service.gov.uk/"
target="_blank"
>UK Government - Compare School Performance</a
>
</p>
</div>
</div>
</footer>
<script src="/static/app.js"></script>
</body>
</html>
-8
View File
@@ -1,8 +0,0 @@
User-agent: *
Allow: /
Allow: /compare
Allow: /rankings
Disallow: /api/
Sitemap: https://schoolcompare.co.uk/sitemap.xml
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://schoolcompare.co.uk/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://schoolcompare.co.uk/compare</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://schoolcompare.co.uk/rankings</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
-1903
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY scripts/ ./scripts/
COPY server.py .
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8001"]
-6
View File
@@ -1,6 +0,0 @@
FROM alpine:3.19
RUN apk add --no-cache curl
COPY flows/ /flows/
COPY docker/kestra-init.sh /kestra-init.sh
RUN chmod +x /kestra-init.sh
CMD ["/kestra-init.sh"]
-59
View File
@@ -1,59 +0,0 @@
#!/bin/sh
set -e
KESTRA_URL="${KESTRA_URL:-http://kestra:8080}"
MAX_WAIT=120
# Basic auth — set KESTRA_USER / KESTRA_PASSWORD if authentication is enabled
AUTH=""
if [ -n "$KESTRA_USER" ] && [ -n "$KESTRA_PASSWORD" ]; then
AUTH="-u ${KESTRA_USER}:${KESTRA_PASSWORD}"
fi
echo "Waiting for Kestra API at ${KESTRA_URL}..."
elapsed=0
until curl -sf $AUTH "${KESTRA_URL}/api/v1/flows/search" > /dev/null 2>&1; do
if [ "$elapsed" -ge "$MAX_WAIT" ]; then
echo "ERROR: Kestra API not reachable after ${MAX_WAIT}s"
exit 1
fi
sleep 5
elapsed=$((elapsed + 5))
done
echo "Kestra API is ready."
echo "Importing flows..."
for f in /flows/*.yml; do
name="$(basename "$f")"
echo " -> $name"
http_code=$(curl -s $AUTH -o /tmp/kestra_resp -w "%{http_code}" \
-X POST "${KESTRA_URL}/api/v1/flows" \
-H "Content-Type: application/x-yaml" \
--data-binary "@${f}")
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
echo " created"
elif [ "$http_code" = "409" ]; then
ns=$(grep '^namespace:' "$f" | awk '{print $2}')
id=$(grep '^id:' "$f" | awk '{print $2}')
http_code2=$(curl -s $AUTH -o /tmp/kestra_resp -w "%{http_code}" \
-X PUT "${KESTRA_URL}/api/v1/flows/${ns}/${id}" \
-H "Content-Type: application/x-yaml" \
--data-binary "@${f}")
if [ "$http_code2" = "200" ] || [ "$http_code2" = "201" ]; then
echo " updated"
else
echo " ERROR updating $name: HTTP $http_code2"
cat /tmp/kestra_resp; echo
exit 1
fi
else
echo " ERROR importing $name: HTTP $http_code"
cat /tmp/kestra_resp; echo
exit 1
fi
done
echo "All flows imported."
-26
View File
@@ -1,26 +0,0 @@
id: admissions-annual-update
namespace: schoolcompare.data
description: Download and load school admissions data via EES API
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 4 1 7 *" # 1 July annually at 04:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/admissions?action=download
method: POST
timeout: PT20M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/admissions?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 3
interval: PT15M
-26
View File
@@ -1,26 +0,0 @@
id: census-annual-update
namespace: schoolcompare.data
description: Download and load School Census (SPC) data via EES API
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 4 1 9 *" # 1 September annually at 04:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/census?action=download
method: POST
timeout: PT20M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/census?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 3
interval: PT15M
-26
View File
@@ -1,26 +0,0 @@
id: finance-annual-update
namespace: schoolcompare.data
description: Fetch FBIT financial benchmarking data from DfE API for all schools
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 4 1 12 *" # 1 December annually at 04:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/finance?action=download
method: POST
timeout: PT120M # Fetches per-school from API — ~20k schools
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/finance?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 2
interval: PT30M
-31
View File
@@ -1,31 +0,0 @@
id: gias-weekly-update
namespace: schoolcompare.data
description: Download and load GIAS (Get Information About Schools) bulk CSV
triggers:
- id: weekly-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 3 * * 0" # Every Sunday at 03:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/gias?action=download
method: POST
timeout: PT30M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/gias?action=load
method: POST
timeout: PT30M
errors:
- id: notify-failure
type: io.kestra.plugin.core.log.Log
message: "GIAS update FAILED: {{ error.message }}"
retry:
type: constant
maxAttempts: 3
interval: PT10M
-26
View File
@@ -1,26 +0,0 @@
id: idaci-annual-check
namespace: schoolcompare.data
description: Download IoD2019 IDACI file and compute deprivation scores for all schools
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 5 1 1 *" # 1 January annually at 05:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/idaci?action=download
method: POST
timeout: PT10M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/idaci?action=load
method: POST
timeout: PT60M
retry:
type: constant
maxAttempts: 2
interval: PT30M
-23
View File
@@ -1,23 +0,0 @@
id: ks2-reimport
namespace: schoolcompare.data
description: Re-import KS2 attainment data from bundled CSV files (use after DB wipe)
# No scheduled trigger — run manually from the Kestra UI when needed.
tasks:
- id: reimport
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/ks2?action=load
method: POST
allowFailed: false
timeout: PT30S # fire-and-forget; backend runs migration in background
errors:
- id: notify-failure
type: io.kestra.plugin.core.log.Log
message: "KS2 re-import FAILED: {{ error.message }}"
retry:
type: constant
maxAttempts: 2
interval: PT5M
-33
View File
@@ -1,33 +0,0 @@
id: ofsted-monthly-update
namespace: schoolcompare.data
description: Download and load Ofsted Monthly Management Information CSV
triggers:
- id: monthly-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 2 1 * *" # 1st of each month at 02:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/ofsted?action=download
method: POST
allowFailed: false
timeout: PT10M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/ofsted?action=load
method: POST
allowFailed: false
timeout: PT30M
errors:
- id: notify-failure
type: io.kestra.plugin.core.log.Log
message: "Ofsted update FAILED: {{ error.message }}"
retry:
type: constant
maxAttempts: 3
interval: PT10M
-31
View File
@@ -1,31 +0,0 @@
id: parent-view-monthly-check
namespace: schoolcompare.data
description: Download and load Ofsted Parent View open data (released ~3x/year)
triggers:
- id: monthly-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 3 1 * *" # 1st of each month at 03:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/parent_view?action=download
method: POST
timeout: PT10M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/parent_view?action=load
method: POST
timeout: PT20M
errors:
- id: notify-failure
type: io.kestra.plugin.core.log.Log
message: "Parent View update FAILED: {{ error.message }}"
retry:
type: constant
maxAttempts: 3
interval: PT10M
-26
View File
@@ -1,26 +0,0 @@
id: phonics-annual-update
namespace: schoolcompare.data
description: Download and load Phonics Screening Check data via EES API
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 5 1 9 *" # 1 September annually at 05:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/phonics?action=download
method: POST
timeout: PT20M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/phonics?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 3
interval: PT15M
-26
View File
@@ -1,26 +0,0 @@
id: sen-detail-annual-update
namespace: schoolcompare.data
description: Download and load SEN primary need breakdown via EES API
triggers:
- id: annual-schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 4 15 9 *" # 15 September annually at 04:00
tasks:
- id: download
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/sen_detail?action=download
method: POST
timeout: PT20M
- id: load
type: io.kestra.plugin.core.http.Request
uri: http://integrator:8001/run/sen_detail?action=load
method: POST
timeout: PT30M
retry:
type: constant
maxAttempts: 3
interval: PT15M
-7
View File
@@ -1,7 +0,0 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
requests==2.32.3
pandas==2.2.3
openpyxl==3.1.5
psycopg2-binary==2.9.9
sqlalchemy==2.0.35
-14
View File
@@ -1,14 +0,0 @@
"""Configuration for the data integrator."""
import os
from pathlib import Path
DATABASE_URL = os.environ.get(
"DATABASE_URL",
"postgresql://schoolcompare:schoolcompare@db:5432/schoolcompare",
)
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
SUPPLEMENTARY_DIR = DATA_DIR / "supplementary"
BACKEND_URL = os.environ.get("BACKEND_URL", "http://backend:80")
ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "changeme")
-23
View File
@@ -1,23 +0,0 @@
"""Database connection for the integrator."""
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from config import DATABASE_URL
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@contextmanager
def get_session():
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
-184
View File
@@ -1,184 +0,0 @@
"""
School Admissions data downloader and loader.
Source: EES publication "primary-and-secondary-school-applications-and-offers"
Content API release ZIP → supporting-files/AppsandOffers_*_SchoolLevel*.csv
Update: Annual (June/July post-offer round)
"""
import argparse
import re
import sys
from pathlib import Path
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
from sources.ees import download_release_zip_csv
DEST_DIR = SUPPLEMENTARY_DIR / "admissions"
PUBLICATION_SLUG = "primary-and-secondary-school-applications-and-offers"
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", "Z", ""}
# Maps actual CSV column names → internal field names
COLUMN_MAP = {
# School identifier
"school_urn": "urn",
# Year — e.g. 202526 → 2025
"time_period": "time_period_raw",
# PAN (places offered)
"total_number_places_offered": "pan",
# Applications (total times put as any preference)
"times_put_as_any_preferred_school": "total_applications",
# 1st-preference applications
"times_put_as_1st_preference": "times_1st_pref",
# 1st-preference offers
"number_1st_preference_offers": "offers_1st_pref",
}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "admissions") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
dest_file = dest / "admissions_school_level_latest.csv"
return download_release_zip_csv(
PUBLICATION_SLUG,
dest_file,
zip_member_keyword="schoollevel",
)
def _parse_int(val) -> int | None:
if pd.isna(val):
return None
s = str(val).strip().upper().replace(",", "")
if s in NULL_VALUES:
return None
try:
return int(float(s))
except ValueError:
return None
def _parse_pct(val) -> float | None:
if pd.isna(val):
return None
s = str(val).strip().upper().replace("%", "")
if s in NULL_VALUES:
return None
try:
return float(s)
except ValueError:
return None
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "admissions") if data_dir else DEST_DIR
files = sorted(dest.glob("*.csv"))
if not files:
raise FileNotFoundError(f"No admissions CSV found in {dest}")
path = files[-1]
print(f" Admissions: loading {path} ...")
df = pd.read_csv(path, encoding="utf-8-sig", low_memory=False)
# Rename columns we care about
df.rename(columns=COLUMN_MAP, inplace=True)
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
# Filter to primary schools only
if "school_phase" in df.columns:
df = df[df["school_phase"].str.lower() == "primary"]
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
# Derive year from time_period (e.g. 202526 → 2025)
def _extract_year(val) -> int | None:
s = str(val).strip()
m = re.match(r"(\d{4})\d{2}", s)
if m:
return int(m.group(1))
m2 = re.search(r"20(\d{2})", s)
if m2:
return int("20" + m2.group(1))
return None
if "time_period_raw" in df.columns:
df["year"] = df["time_period_raw"].apply(_extract_year)
else:
year_m = re.search(r"20(\d{2})", path.stem)
df["year"] = int("20" + year_m.group(1)) if year_m else None
df = df.dropna(subset=["year"])
df["year"] = df["year"].astype(int)
# Keep most recent year per school (file may contain multiple years)
df = df.sort_values("year", ascending=False).groupby("urn").first().reset_index()
inserted = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
year = int(row["year"])
pan = _parse_int(row.get("pan"))
total_apps = _parse_int(row.get("total_applications"))
times_1st = _parse_int(row.get("times_1st_pref"))
offers_1st = _parse_int(row.get("offers_1st_pref"))
# % of 1st-preference applicants who received an offer
if times_1st and times_1st > 0 and offers_1st is not None:
pct_1st = round(offers_1st / times_1st * 100, 1)
else:
pct_1st = None
oversubscribed = (
True if (pan and times_1st and times_1st > pan) else
False if (pan and times_1st and times_1st <= pan) else
None
)
session.execute(
text("""
INSERT INTO school_admissions
(urn, year, published_admission_number, total_applications,
first_preference_offers_pct, oversubscribed)
VALUES (:urn, :year, :pan, :total_apps, :pct_1st, :oversubscribed)
ON CONFLICT (urn, year) DO UPDATE SET
published_admission_number = EXCLUDED.published_admission_number,
total_applications = EXCLUDED.total_applications,
first_preference_offers_pct = EXCLUDED.first_preference_offers_pct,
oversubscribed = EXCLUDED.oversubscribed
"""),
{
"urn": urn, "year": year, "pan": pan,
"total_apps": total_apps, "pct_1st": pct_1st,
"oversubscribed": oversubscribed,
},
)
inserted += 1
if inserted % 5000 == 0:
session.flush()
print(f" Processed {inserted} records...")
print(f" Admissions: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)
-148
View File
@@ -1,148 +0,0 @@
"""
School Census (SPC) downloader and loader.
Source: EES publication "schools-pupils-and-their-characteristics"
Update: Annual (June)
Adds: class_size_avg, ethnicity breakdown by school
"""
import argparse
import re
import sys
from pathlib import Path
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
from sources.ees import get_latest_csv_url, download_csv
DEST_DIR = SUPPLEMENTARY_DIR / "census"
PUBLICATION_SLUG = "schools-pupils-and-their-characteristics"
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", ""}
COLUMN_MAP = {
"URN": "urn",
"urn": "urn",
"YEAR": "year",
"Year": "year",
# Class size
"average_class_size": "class_size_avg",
"AVCLAS": "class_size_avg",
"avg_class_size": "class_size_avg",
# Ethnicity — DfE uses ethnicity major group percentages
"perc_white": "ethnicity_white_pct",
"perc_asian": "ethnicity_asian_pct",
"perc_black": "ethnicity_black_pct",
"perc_mixed": "ethnicity_mixed_pct",
"perc_other_ethnic": "ethnicity_other_pct",
"PTWHITE": "ethnicity_white_pct",
"PTASIAN": "ethnicity_asian_pct",
"PTBLACK": "ethnicity_black_pct",
"PTMIXED": "ethnicity_mixed_pct",
"PTOTHER": "ethnicity_other_pct",
}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "census") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
if not url:
raise RuntimeError(f"Could not find CSV URL for census publication")
filename = url.split("/")[-1].split("?")[0] or "census_latest.csv"
return download_csv(url, dest / filename)
def _parse_pct(val) -> float | None:
if pd.isna(val):
return None
s = str(val).strip().upper().replace("%", "")
if s in NULL_VALUES:
return None
try:
return float(s)
except ValueError:
return None
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "census") if data_dir else DEST_DIR
files = sorted(dest.glob("*.csv"))
if not files:
raise FileNotFoundError(f"No census CSV found in {dest}")
path = files[-1]
print(f" Census: loading {path} ...")
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
df.rename(columns=COLUMN_MAP, inplace=True)
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
year = None
m = re.search(r"20(\d{2})", path.stem)
if m:
year = int("20" + m.group(1))
inserted = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
row_year = int(row["year"]) if "year" in df.columns and pd.notna(row.get("year")) else year
if not row_year:
continue
session.execute(
text("""
INSERT INTO school_census
(urn, year, class_size_avg,
ethnicity_white_pct, ethnicity_asian_pct, ethnicity_black_pct,
ethnicity_mixed_pct, ethnicity_other_pct)
VALUES (:urn, :year, :class_size_avg,
:white, :asian, :black, :mixed, :other)
ON CONFLICT (urn, year) DO UPDATE SET
class_size_avg = EXCLUDED.class_size_avg,
ethnicity_white_pct = EXCLUDED.ethnicity_white_pct,
ethnicity_asian_pct = EXCLUDED.ethnicity_asian_pct,
ethnicity_black_pct = EXCLUDED.ethnicity_black_pct,
ethnicity_mixed_pct = EXCLUDED.ethnicity_mixed_pct,
ethnicity_other_pct = EXCLUDED.ethnicity_other_pct
"""),
{
"urn": urn,
"year": row_year,
"class_size_avg": _parse_pct(row.get("class_size_avg")),
"white": _parse_pct(row.get("ethnicity_white_pct")),
"asian": _parse_pct(row.get("ethnicity_asian_pct")),
"black": _parse_pct(row.get("ethnicity_black_pct")),
"mixed": _parse_pct(row.get("ethnicity_mixed_pct")),
"other": _parse_pct(row.get("ethnicity_other_pct")),
},
)
inserted += 1
if inserted % 5000 == 0:
session.flush()
print(f" Census: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)
-111
View File
@@ -1,111 +0,0 @@
"""
Shared EES (Explore Education Statistics) API client.
Two APIs are available:
- Statistics API: https://api.education.gov.uk/statistics/v1 (only ~13 publications)
- Content API: https://content.explore-education-statistics.service.gov.uk/api
Covers all publications; use this for admissions and other data not in the stats API.
Download all files for a release as a ZIP from /api/releases/{id}/files.
"""
import io
import zipfile
from pathlib import Path
from typing import Optional
import requests
STATS_API_BASE = "https://api.education.gov.uk/statistics/v1"
CONTENT_API_BASE = "https://content.explore-education-statistics.service.gov.uk/api"
TIMEOUT = 60
def get_publication_files(publication_slug: str) -> list[dict]:
"""Return list of data-set file descriptors for a publication (statistics API)."""
url = f"{STATS_API_BASE}/publications/{publication_slug}/data-set-files"
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
return resp.json().get("results", [])
def get_latest_csv_url(publication_slug: str, keyword: str = "") -> Optional[str]:
"""
Find the most recent CSV download URL for a publication (statistics API).
Optionally filter by a keyword in the file name.
"""
files = get_publication_files(publication_slug)
for entry in files:
name = entry.get("name", "").lower()
if keyword and keyword.lower() not in name:
continue
csv_url = entry.get("csvDownloadUrl") or entry.get("file", {}).get("url")
if csv_url:
return csv_url
return None
def get_content_release_id(publication_slug: str) -> str:
"""Return the latest release ID for a publication via the content API."""
url = f"{CONTENT_API_BASE}/publications/{publication_slug}/releases/latest"
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
return resp.json()["id"]
def download_release_zip_csv(
publication_slug: str,
dest_path: Path,
zip_member_keyword: str = "",
) -> Path:
"""
Download the full-release ZIP from the EES content API and extract one CSV.
If zip_member_keyword is given, the first member whose path contains that
keyword (case-insensitive) is extracted; otherwise the first .csv found is used.
Returns dest_path (the extracted CSV file).
"""
if dest_path.exists():
print(f" EES: {dest_path.name} already exists, skipping.")
return dest_path
release_id = get_content_release_id(publication_slug)
zip_url = f"{CONTENT_API_BASE}/releases/{release_id}/files"
print(f" EES: downloading release ZIP for '{publication_slug}' ...")
resp = requests.get(zip_url, timeout=300, stream=True)
resp.raise_for_status()
data = b"".join(resp.iter_content(chunk_size=65536))
with zipfile.ZipFile(io.BytesIO(data)) as z:
members = z.namelist()
target = None
kw = zip_member_keyword.lower()
for m in members:
if m.endswith(".csv") and (not kw or kw in m.lower()):
target = m
break
if not target:
raise ValueError(
f"No CSV matching '{zip_member_keyword}' in ZIP. Members: {members}"
)
print(f" EES: extracting '{target}' ...")
dest_path.parent.mkdir(parents=True, exist_ok=True)
with z.open(target) as src, open(dest_path, "wb") as dst:
dst.write(src.read())
print(f" EES: saved {dest_path} ({dest_path.stat().st_size // 1024} KB)")
return dest_path
def download_csv(url: str, dest_path: Path) -> Path:
"""Download a CSV from EES to dest_path."""
if dest_path.exists():
print(f" EES: {dest_path.name} already exists, skipping.")
return dest_path
print(f" EES: downloading {url} ...")
resp = requests.get(url, timeout=300, stream=True)
resp.raise_for_status()
dest_path.parent.mkdir(parents=True, exist_ok=True)
with open(dest_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
f.write(chunk)
print(f" EES: saved {dest_path} ({dest_path.stat().st_size // 1024} KB)")
return dest_path
-143
View File
@@ -1,143 +0,0 @@
"""
FBIT (Financial Benchmarking and Insights Tool) financial data loader.
Source: https://schools-financial-benchmarking.service.gov.uk/api/
Update: Annual (December — data for the prior financial year)
"""
import argparse
import sys
import time
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
DEST_DIR = SUPPLEMENTARY_DIR / "finance"
API_BASE = "https://schools-financial-benchmarking.service.gov.uk/api"
RATE_LIMIT_DELAY = 0.1 # seconds between requests
def download(data_dir: Path | None = None) -> Path:
"""
Fetch per-URN financial data from FBIT API and save as CSV.
Batches all school URNs from the database.
"""
dest = (data_dir / "supplementary" / "finance") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
# Determine year from API (use current year minus 1 for completed financials)
from datetime import date
year = date.today().year - 1
dest_file = dest / f"fbit_{year}.csv"
if dest_file.exists():
print(f" Finance: {dest_file.name} already exists, skipping download.")
return dest_file
# Get all URNs from the database
with get_session() as session:
from sqlalchemy import text
rows = session.execute(text("SELECT urn FROM schools")).fetchall()
urns = [r[0] for r in rows]
print(f" Finance: fetching FBIT data for {len(urns)} schools (year {year}) ...")
records = []
errors = 0
for i, urn in enumerate(urns):
if i % 500 == 0:
print(f" {i}/{len(urns)} ...")
try:
resp = requests.get(
f"{API_BASE}/schoolFinancialDataObject/{urn}",
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
if data:
records.append({
"urn": urn,
"year": year,
"per_pupil_spend": data.get("totalExpenditure") and
data.get("numberOfPupils") and
round(data["totalExpenditure"] / data["numberOfPupils"], 2),
"staff_cost_pct": data.get("staffCostPercent"),
"teacher_cost_pct": data.get("teachingStaffCostPercent"),
"support_staff_cost_pct": data.get("educationSupportStaffCostPercent"),
"premises_cost_pct": data.get("premisesStaffCostPercent"),
})
elif resp.status_code not in (404, 400):
errors += 1
except Exception:
errors += 1
time.sleep(RATE_LIMIT_DELAY)
df = pd.DataFrame(records)
df.to_csv(dest_file, index=False)
print(f" Finance: saved {len(records)} records to {dest_file} ({errors} errors)")
return dest_file
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "finance") if data_dir else DEST_DIR
files = sorted(dest.glob("fbit_*.csv"))
if not files:
raise FileNotFoundError(f"No finance CSV found in {dest}")
path = files[-1]
print(f" Finance: loading {path} ...")
df = pd.read_csv(path)
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
inserted = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
session.execute(
text("""
INSERT INTO school_finance
(urn, year, per_pupil_spend, staff_cost_pct, teacher_cost_pct,
support_staff_cost_pct, premises_cost_pct)
VALUES (:urn, :year, :per_pupil, :staff, :teacher, :support, :premises)
ON CONFLICT (urn, year) DO UPDATE SET
per_pupil_spend = EXCLUDED.per_pupil_spend,
staff_cost_pct = EXCLUDED.staff_cost_pct,
teacher_cost_pct = EXCLUDED.teacher_cost_pct,
support_staff_cost_pct = EXCLUDED.support_staff_cost_pct,
premises_cost_pct = EXCLUDED.premises_cost_pct
"""),
{
"urn": int(row["urn"]),
"year": int(row["year"]),
"per_pupil": float(row["per_pupil_spend"]) if pd.notna(row.get("per_pupil_spend")) else None,
"staff": float(row["staff_cost_pct"]) if pd.notna(row.get("staff_cost_pct")) else None,
"teacher": float(row["teacher_cost_pct"]) if pd.notna(row.get("teacher_cost_pct")) else None,
"support": float(row["support_staff_cost_pct"]) if pd.notna(row.get("support_staff_cost_pct")) else None,
"premises": float(row["premises_cost_pct"]) if pd.notna(row.get("premises_cost_pct")) else None,
},
)
inserted += 1
if inserted % 2000 == 0:
session.flush()
print(f" Finance: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)
-159
View File
@@ -1,159 +0,0 @@
"""
GIAS (Get Information About Schools) bulk CSV downloader and loader.
Source: https://get-information-schools.service.gov.uk/Downloads
Update: Daily; we refresh weekly.
Adds: website, headteacher_name, capacity, trust_name, trust_uid, gender, nursery_provision
"""
import argparse
import sys
from datetime import date
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
DEST_DIR = SUPPLEMENTARY_DIR / "gias"
# GIAS bulk download URL — date is injected at runtime
GIAS_URL_TEMPLATE = "https://ea-edubase-api-prod.azurewebsites.net/edubase/downloads/public/edubasealldata{date}.csv"
COLUMN_MAP = {
"URN": "urn",
"SchoolWebsite": "website",
"SchoolCapacity": "capacity",
"TrustName": "trust_name",
"TrustUID": "trust_uid",
"Gender (name)": "gender",
"NurseryProvision (name)": "nursery_provision_raw",
"HeadTitle": "head_title",
"HeadFirstName": "head_first",
"HeadLastName": "head_last",
}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "gias") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
today = date.today().strftime("%Y%m%d")
url = GIAS_URL_TEMPLATE.format(date=today)
filename = f"gias_{today}.csv"
dest_file = dest / filename
if dest_file.exists():
print(f" GIAS: {filename} already exists, skipping download.")
return dest_file
print(f" GIAS: downloading {url} ...")
resp = requests.get(url, timeout=300, stream=True)
# GIAS may not have today's file yet — fall back to yesterday
if resp.status_code == 404:
from datetime import timedelta
yesterday = (date.today() - timedelta(days=1)).strftime("%Y%m%d")
url = GIAS_URL_TEMPLATE.format(date=yesterday)
filename = f"gias_{yesterday}.csv"
dest_file = dest / filename
if dest_file.exists():
print(f" GIAS: {filename} already exists, skipping download.")
return dest_file
resp = requests.get(url, timeout=300, stream=True)
resp.raise_for_status()
with open(dest_file, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
f.write(chunk)
print(f" GIAS: saved {dest_file} ({dest_file.stat().st_size // 1024} KB)")
return dest_file
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
if path is None:
dest = (data_dir / "supplementary" / "gias") if data_dir else DEST_DIR
files = sorted(dest.glob("gias_*.csv"))
if not files:
raise FileNotFoundError(f"No GIAS CSV found in {dest}")
path = files[-1]
print(f" GIAS: loading {path} ...")
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
df.rename(columns=COLUMN_MAP, inplace=True)
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
# Build headteacher_name from parts
def build_name(row):
parts = [
str(row.get("head_title", "") or "").strip(),
str(row.get("head_first", "") or "").strip(),
str(row.get("head_last", "") or "").strip(),
]
return " ".join(p for p in parts if p) or None
df["headteacher_name"] = df.apply(build_name, axis=1)
df["nursery_provision"] = df.get("nursery_provision_raw", pd.Series()).apply(
lambda v: True if str(v).strip().lower().startswith("has") else False if pd.notna(v) else None
)
def clean_str(val):
s = str(val).strip() if pd.notna(val) else None
return s if s and s.lower() not in ("nan", "none", "") else None
updated = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
session.execute(
text("""
UPDATE schools SET
website = :website,
headteacher_name = :headteacher_name,
capacity = :capacity,
trust_name = :trust_name,
trust_uid = :trust_uid,
gender = :gender,
nursery_provision = :nursery_provision
WHERE urn = :urn
"""),
{
"urn": urn,
"website": clean_str(row.get("website")),
"headteacher_name": row.get("headteacher_name"),
"capacity": int(row["capacity"]) if pd.notna(row.get("capacity")) and str(row.get("capacity")).strip().isdigit() else None,
"trust_name": clean_str(row.get("trust_name")),
"trust_uid": clean_str(row.get("trust_uid")),
"gender": clean_str(row.get("gender")),
"nursery_provision": row.get("nursery_provision"),
},
)
updated += 1
if updated % 5000 == 0:
session.flush()
print(f" Updated {updated} schools...")
print(f" GIAS: updated {updated} school records")
return {"inserted": 0, "updated": updated, "skipped": 0}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
path = download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)
-176
View File
@@ -1,176 +0,0 @@
"""
IDACI (Income Deprivation Affecting Children Index) loader.
Source: English Indices of Deprivation 2019
https://www.gov.uk/government/statistics/english-indices-of-deprivation-2019
This is a one-time download (5-yearly release). We join school postcodes to LSOAs
via postcodes.io, then look up IDACI scores from the IoD2019 file.
Update: ~5-yearly (next release expected 2025/26)
"""
import argparse
import sys
from pathlib import Path
import pandas as pd
import requests
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
DEST_DIR = SUPPLEMENTARY_DIR / "idaci"
# IoD 2019 supplementary data — "Income Deprivation Affecting Children Index (IDACI)"
IOD_2019_URL = (
"https://assets.publishing.service.gov.uk/government/uploads/system/uploads/"
"attachment_data/file/833970/File_1_-_IMD2019_Index_of_Multiple_Deprivation.xlsx"
)
POSTCODES_IO_BATCH = "https://api.postcodes.io/postcodes"
BATCH_SIZE = 100
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "idaci") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
filename = "iod2019_idaci.xlsx"
dest_file = dest / filename
if dest_file.exists():
print(f" IDACI: {filename} already exists, skipping download.")
return dest_file
print(f" IDACI: downloading IoD2019 file ...")
resp = requests.get(IOD_2019_URL, timeout=300, stream=True)
resp.raise_for_status()
with open(dest_file, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
f.write(chunk)
print(f" IDACI: saved {dest_file}")
return dest_file
def _postcode_to_lsoa(postcodes: list[str]) -> dict[str, str]:
"""Batch-resolve postcodes to LSOA codes via postcodes.io."""
result = {}
valid = [p.strip().upper() for p in postcodes if p and len(str(p).strip()) >= 5]
valid = list(set(valid))
for i in range(0, len(valid), BATCH_SIZE):
batch = valid[i:i + BATCH_SIZE]
try:
resp = requests.post(POSTCODES_IO_BATCH, json={"postcodes": batch}, timeout=30)
if resp.status_code == 200:
for item in resp.json().get("result", []):
if item and item.get("result"):
lsoa = item["result"].get("lsoa")
if lsoa:
result[item["query"].upper()] = lsoa
except Exception as e:
print(f" Warning: postcodes.io batch failed: {e}")
return result
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
dest = (data_dir / "supplementary" / "idaci") if data_dir else DEST_DIR
if path is None:
files = sorted(dest.glob("*.xlsx"))
if not files:
raise FileNotFoundError(f"No IDACI file found in {dest}")
path = files[-1]
print(f" IDACI: loading IoD2019 from {path} ...")
# IoD2019 File 1 — sheet "IoD2019 IDACI" or similar
try:
iod_df = pd.read_excel(path, sheet_name=None)
# Find sheet with IDACI data
idaci_sheet = None
for name, df in iod_df.items():
if "IDACI" in name.upper() or "IDACI" in str(df.columns.tolist()).upper():
idaci_sheet = name
break
if idaci_sheet is None:
idaci_sheet = list(iod_df.keys())[0]
df_iod = iod_df[idaci_sheet]
except Exception as e:
raise RuntimeError(f"Could not read IoD2019 file: {e}")
# Normalise column names — IoD2019 uses specific headers
col_lsoa = next((c for c in df_iod.columns if "LSOA" in str(c).upper() and "code" in str(c).lower()), None)
col_score = next((c for c in df_iod.columns if "IDACI" in str(c).upper() and "score" in str(c).lower()), None)
col_rank = next((c for c in df_iod.columns if "IDACI" in str(c).upper() and "rank" in str(c).lower()), None)
if not col_lsoa or not col_score:
print(f" IDACI columns available: {list(df_iod.columns)[:20]}")
raise ValueError("Could not find LSOA code or IDACI score columns")
df_iod = df_iod[[col_lsoa, col_score]].copy()
df_iod.columns = ["lsoa_code", "idaci_score"]
df_iod = df_iod.dropna()
# Compute decile from rank (or from score distribution)
total = len(df_iod)
df_iod = df_iod.sort_values("idaci_score", ascending=False)
df_iod["idaci_decile"] = (pd.qcut(df_iod["idaci_score"], 10, labels=False) + 1).astype(int)
# Decile 1 = most deprived (highest IDACI score)
df_iod["idaci_decile"] = 11 - df_iod["idaci_decile"]
lsoa_lookup = df_iod.set_index("lsoa_code")[["idaci_score", "idaci_decile"]].to_dict("index")
print(f" IDACI: loaded {len(lsoa_lookup)} LSOA records")
# Fetch all school postcodes from the database
with get_session() as session:
from sqlalchemy import text
rows = session.execute(text("SELECT urn, postcode FROM schools WHERE postcode IS NOT NULL")).fetchall()
postcodes = [r[1] for r in rows]
print(f" IDACI: resolving {len(postcodes)} postcodes via postcodes.io ...")
pc_to_lsoa = _postcode_to_lsoa(postcodes)
print(f" IDACI: resolved {len(pc_to_lsoa)} postcodes to LSOAs")
inserted = skipped = 0
with get_session() as session:
from sqlalchemy import text
for urn, postcode in rows:
lsoa = pc_to_lsoa.get(str(postcode).strip().upper())
if not lsoa:
skipped += 1
continue
iod = lsoa_lookup.get(lsoa)
if not iod:
skipped += 1
continue
session.execute(
text("""
INSERT INTO school_deprivation (urn, lsoa_code, idaci_score, idaci_decile)
VALUES (:urn, :lsoa, :score, :decile)
ON CONFLICT (urn) DO UPDATE SET
lsoa_code = EXCLUDED.lsoa_code,
idaci_score = EXCLUDED.idaci_score,
idaci_decile = EXCLUDED.idaci_decile
"""),
{"urn": urn, "lsoa": lsoa, "score": float(iod["idaci_score"]), "decile": int(iod["idaci_decile"])},
)
inserted += 1
if inserted % 2000 == 0:
session.flush()
print(f" IDACI: upserted {inserted}, skipped {skipped}")
return {"inserted": inserted, "updated": 0, "skipped": skipped}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
parser.add_argument("--data-dir", type=Path, default=None)
args = parser.parse_args()
if args.action in ("download", "all"):
download(args.data_dir)
if args.action in ("load", "all"):
load(data_dir=args.data_dir)

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