Compare commits

...
Author SHA1 Message Date
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
59 changed files with 1998 additions and 801 deletions
+4 -1
View File
@@ -51,11 +51,14 @@ jobs:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
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
+1 -1
View File
@@ -1,2 +1,2 @@
venv
backend/__pycache__
__pycache__/
+26 -10
View File
@@ -33,7 +33,7 @@ from .data_loader import (
)
from .data_loader import get_data_info as get_db_info
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"}
@@ -416,10 +416,17 @@ async def get_schools(
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()]
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)]
# 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"]
@@ -572,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:
@@ -582,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", ""),
@@ -591,6 +603,7 @@ 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"),
"latitude": latest.get("latitude"),
"longitude": latest.get("longitude"),
"phase": latest.get("phase"),
@@ -601,11 +614,14 @@ async def get_school_details(request: Request, urn: int):
"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 [],
+30 -25
View File
@@ -3,18 +3,21 @@ Data loading module — reads from marts.* tables built by dbt.
Provides efficient queries with caching.
"""
import logging
import pandas as pd
import numpy as np
from typing import Optional, Dict, Tuple, List
import requests
from sqlalchemy import text
import sqlalchemy.exc
from sqlalchemy.orm import Session
from .config import settings
from .database import SessionLocal, engine
from .models import (
DimSchool, DimLocation, KS2Performance,
FactOfstedInspection, FactParentView, FactAdmissions,
FactOfstedInspection, FactAdmissions,
FactDeprivation, FactFinance, FactPupilCharacteristics,
)
from .schemas import SCHOOL_TYPE_MAP
@@ -125,6 +128,7 @@ _MAIN_QUERY = text("""
s.religious_character AS religious_denomination,
s.gender,
s.age_range,
s.has_sixth_form,
s.admissions_policy,
s.capacity,
s.total_pupils AS gias_total_pupils,
@@ -214,11 +218,36 @@ _MAIN_QUERY = text("""
ORDER BY s.school_name, p.year
""")
# Fallback used when marts.dim_school predates the has_sixth_form column
# (i.e. the nightly dbt pipeline hasn't rebuilt the mart yet on this DB).
# Keeps the column present as NULL so downstream code — including the
# app.py fallback branch — behaves as designed instead of KeyError-ing.
_MAIN_QUERY_NO_SIXTH_FORM = text(
str(_MAIN_QUERY).replace("s.has_sixth_form,", "NULL AS has_sixth_form,")
)
assert "NULL AS has_sixth_form" in str(_MAIN_QUERY_NO_SIXTH_FORM), (
"expected replacement of 's.has_sixth_form,' to have taken effect"
)
def load_school_data_as_dataframe() -> pd.DataFrame:
"""Load all school + KS2 data as a pandas DataFrame."""
try:
df = pd.read_sql(_MAIN_QUERY, engine)
except sqlalchemy.exc.ProgrammingError as exc:
if "has_sixth_form" not in str(exc):
print(f"Warning: Could not load school data from marts: {exc}")
return pd.DataFrame()
logging.getLogger(__name__).warning(
"marts.dim_school is missing has_sixth_form (pipeline hasn't "
"rebuilt the mart yet on this DB) — retrying without it: %s",
exc,
)
try:
df = pd.read_sql(_MAIN_QUERY_NO_SIXTH_FORM, engine)
except Exception as exc2:
print(f"Warning: Could not load school data from marts: {exc2}")
return pd.DataFrame()
except Exception as exc:
print(f"Warning: Could not load school data from marts: {exc}")
return pd.DataFrame()
@@ -446,30 +475,6 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
else None
)
# Parent View
pv = safe_query(FactParentView, "urn")
result["parent_view"] = (
{
"survey_date": pv.survey_date.isoformat() if pv.survey_date else None,
"total_responses": pv.total_responses,
"q_happy_pct": pv.q_happy_pct,
"q_safe_pct": pv.q_safe_pct,
"q_behaviour_pct": pv.q_behaviour_pct,
"q_bullying_pct": pv.q_bullying_pct,
"q_communication_pct": pv.q_communication_pct,
"q_progress_pct": pv.q_progress_pct,
"q_teaching_pct": pv.q_teaching_pct,
"q_information_pct": pv.q_information_pct,
"q_curriculum_pct": pv.q_curriculum_pct,
"q_future_pct": pv.q_future_pct,
"q_leadership_pct": pv.q_leadership_pct,
"q_wellbeing_pct": pv.q_wellbeing_pct,
"q_recommend_pct": pv.q_recommend_pct,
}
if pv
else None
)
# Census (latest year of fact_pupil_characteristics)
pc = safe_query(FactPupilCharacteristics, "urn", "year")
result["census"] = (
+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)
+1 -23
View File
@@ -24,6 +24,7 @@ class DimSchool(Base):
religious_character = Column(String(100))
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))
@@ -149,29 +150,6 @@ class FactOfstedInspection(Base):
report_url = Column(Text)
class FactParentView(Base):
"""Ofsted Parent View survey — latest per school."""
__tablename__ = "fact_parent_view"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
survey_date = Column(Date)
total_responses = Column(Integer)
q_happy_pct = Column(Float)
q_safe_pct = Column(Float)
q_behaviour_pct = Column(Float)
q_bullying_pct = Column(Float)
q_communication_pct = Column(Float)
q_progress_pct = Column(Float)
q_teaching_pct = Column(Float)
q_information_pct = Column(Float)
q_curriculum_pct = Column(Float)
q_future_pct = Column(Float)
q_leadership_pct = Column(Float)
q_wellbeing_pct = Column(Float)
q_recommend_pct = Column(Float)
class FactAdmissions(Base):
"""School admissions — one row per URN per year."""
__tablename__ = "fact_admissions"
+1
View File
@@ -543,6 +543,7 @@ SCHOOL_COLUMNS = [
"postcode",
"religious_denomination",
"age_range",
"has_sixth_form",
"gender",
"admissions_policy",
"ofsted_grade",
View File
+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"
+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",
}
+1 -2
View File
@@ -79,8 +79,7 @@ fail the E2E gate. That's the point: staging absorbs the risk.
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`,
`school_data_monthly_parent_view`, then the manual-schedule
`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.
@@ -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,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.
+124 -2
View File
@@ -25,6 +25,16 @@ test('home page loads with hero search', async ({ page }) => {
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 });
@@ -50,6 +60,84 @@ test('school detail page renders name and performance data', async ({ page }) =>
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');
@@ -66,6 +154,37 @@ test('comparing two schools shows both side by side', async ({ page }) => {
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();
@@ -81,8 +200,11 @@ test('rankings stay populated after picking a specific year', async ({ page }) =
const yearSelect = page.locator('#year-select');
await expect(yearSelect).toBeVisible({ timeout: 15_000 });
// Pick the first explicit year option (index 0 is the "Latest" default).
const yearValue = await yearSelect.locator('option').nth(1).getAttribute('value');
// 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=/);
+3 -1
View File
@@ -22,7 +22,9 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build argument for FastAPI URL (used by Next.js rewrites at build time)
# Default backend URL for any server-side fetch during `next build`. The
# runtime /api proxy reads FASTAPI_URL per request (see app/api/[...path]),
# so the deployed container's env is what actually routes traffic.
ARG FASTAPI_URL=http://backend:80/api
ENV FASTAPI_URL=${FASTAPI_URL}
@@ -0,0 +1,46 @@
/**
* 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();
});
});
+53
View File
@@ -9,6 +9,8 @@ import {
isValidPostcode,
debounce,
buildOfstedListBadge,
metricKind,
computeYBounds,
} from '@/lib/utils';
describe('formatPercentage', () => {
@@ -159,3 +161,54 @@ describe('buildOfstedListBadge', () => {
expect(badge.cssClass).toBe('ofstedPending');
});
});
describe('metricKind', () => {
it('classifies metrics by key', () => {
expect(metricKind('rwm_expected_pct')).toBe('percentage');
expect(metricKind('absence_rate')).toBe('percentage');
expect(metricKind('reading_progress')).toBe('progress');
expect(metricKind('progress_8_score')).toBe('progress');
expect(metricKind('attainment_8_score')).toBe('score');
expect(metricKind('reading_avg_score')).toBe('score');
});
});
describe('computeYBounds', () => {
it('tightens clustered percentages instead of framing 0-100', () => {
const b = computeYBounds([86, 86, 86, 80, 96], 'percentage');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.max).toBeLessThanOrEqual(100);
expect(b.min).toBeGreaterThan(50);
expect(b.max! - b.min!).toBeGreaterThanOrEqual(10);
});
it('never widens percentages beyond 0-100 for non-negative data', () => {
const b = computeYBounds([2, 5, 98], 'percentage');
expect(b.min).toBe(0);
expect(b.max).toBe(100);
});
it('does not clamp to zero when pct-named trend data is negative', () => {
const b = computeYBounds([-12, -3, 4], 'percentage');
expect(b.min).toBeLessThan(-12);
});
it('keeps progress bounds symmetric around zero', () => {
const b = computeYBounds([-1.2, 0.4, 2.1], 'progress');
expect(b.min).toBe(-b.max!);
expect(b.min).toBeLessThanOrEqual(-1.2);
expect(b.max).toBeGreaterThanOrEqual(2.1);
});
it('fits score metrics without a fixed frame', () => {
const b = computeYBounds([42.3, 48.9, 51.2], 'score');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.min).toBeLessThanOrEqual(42.3);
expect(b.max).toBeGreaterThanOrEqual(51.2);
});
it('returns empty bounds when there is no numeric data', () => {
expect(computeYBounds([null, undefined, NaN], 'percentage')).toEqual({});
expect(computeYBounds([], 'progress')).toEqual({});
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* Runtime proxy for /api/* → the FastAPI backend.
*
* This replaces the old next.config.js `rewrites()` proxy, whose destination
* was baked into the build (routes-manifest.json) from FASTAPI_URL at build
* time. Because one frontend image is promoted staging→prod, a baked hostname
* forced every environment to name the backend identically; a mismatch (e.g.
* a `backend_stg` service) produced `getaddrinfo ENOTFOUND backend`.
*
* A route handler reads process.env.FASTAPI_URL on each request, so the same
* image adapts to whatever the backend is called in each environment.
*/
import { type NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
// FASTAPI_URL already includes the `/api` suffix (e.g. http://backend:80/api).
function backendBase(): string {
return process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
}
// Hop-by-hop / length headers must not be copied across a proxy — undici has
// already decoded the body, so a stale content-encoding/length corrupts it.
const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`;
const headers = new Headers(req.headers);
headers.delete('host');
headers.delete('connection');
const init: RequestInit & { duplex?: 'half' } = {
method: req.method,
headers,
redirect: 'manual',
cache: 'no-store',
};
if (METHODS_WITH_BODY.has(req.method)) {
init.body = req.body;
init.duplex = 'half';
}
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch (err) {
// e.g. DNS failure or connection refused — surface a clean 502 instead of
// an opaque proxy crash so callers can degrade gracefully.
return NextResponse.json({ detail: 'Upstream request failed' }, { status: 502 });
}
const responseHeaders = new Headers(upstream.headers);
for (const h of STRIPPED_RESPONSE_HEADERS) responseHeaders.delete(h);
return new NextResponse(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
}
export {
handler as GET,
handler as HEAD,
handler as POST,
handler as PUT,
handler as PATCH,
handler as DELETE,
handler as OPTIONS,
};
+1 -3
View File
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -189,7 +189,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null}
admissions={admissions ?? null}
senDetail={sen_detail ?? null}
@@ -203,7 +202,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
+32
View File
@@ -0,0 +1,32 @@
/**
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
*
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
* baking the backend host into the build, so one image works in every
* environment. robots.ts points crawlers here.
*/
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
function backendOrigin(): string {
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
return base.replace(/\/api$/, '');
}
export async function GET() {
let upstream: Response;
try {
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
} catch {
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
}
const body = await upstream.text();
return new NextResponse(body, {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
});
}
@@ -0,0 +1,67 @@
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
parent .chartContainer, whose fixed height drives Chart.js sizing via
maintainAspectRatio: false. */
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.canvasBox {
position: relative;
flex: 1 1 auto;
min-height: 0;
}
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
Chart.js's built-in legend (with per-school point shapes). */
.chips {
display: none;
}
@media (max-width: 640px) {
.chips {
/* Two chips per row so long school names don't crowd into a single
line; each chip fills its column and truncates with an ellipsis. */
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding-bottom: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 40px;
min-width: 0;
padding: 4px 10px;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 999px;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.chip[aria-pressed="true"] {
background: rgba(0, 0, 0, .06);
border-color: rgba(0, 0, 0, .35);
}
.chipDot {
flex: 0 0 auto;
width: 10px;
height: 10px;
border-radius: 50%;
}
.chipName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* min-width:0 lets the name shrink inside the grid cell so the
ellipsis kicks in instead of overflowing. */
min-width: 0;
}
}
+116 -45
View File
@@ -1,47 +1,82 @@
/**
* ComparisonChart Component
* Multi-school comparison chart using Chart.js
* Multi-school comparison chart using Chart.js.
*
* Desktop: built-in legend (point-style markers double as per-school shapes).
* Mobile (≤640px): the in-chart legend and axis titles are dropped in favour
* of a chip row above the canvas; tapping a chip highlights that school's
* line and dims the rest. The y-axis auto-fits the data on all viewports so
* clustered schools stay distinguishable.
*/
'use client';
import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js';
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
import '@/lib/chartSetup';
import type { ComparisonData } from '@/lib/types';
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
import {
CHART_COLORS,
CHART_TEXT_COLORS,
computeYBounds,
formatAcademicYear,
metricKind,
rgbToRgba,
} from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics';
import styles from './ComparisonChart.module.css';
interface ComparisonChartProps {
comparisonData: Record<string, ComparisonData>;
/** Ordered as displayed in the school cards, so colours match by index. */
schools: Array<{ urn: number; school_name: string }>;
metric: string;
metricLabel: string;
}
export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) {
// Get all schools and their data
const schools = Object.entries(comparisonData);
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) {
const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
// A focused school that leaves the basket must not linger.
const urnKey = schools.map((s) => s.urn).join(',');
useEffect(() => {
setFocusedUrn(null);
}, [urnKey]);
if (schools.length === 0) {
return <div>No data available</div>;
}
// Get years from first school (assuming all schools have same years)
const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b);
// Union of years across all schools — coverage differs between them.
const years = [
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
].sort((a, b) => a - b);
// Create datasets for each school
const datasets = schools.map(([urn, data], index) => {
const schoolInfo = data.school_info;
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
const data = comparisonData[String(school.urn)];
const color = CHART_COLORS[index % CHART_COLORS.length];
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
return {
label: schoolInfo.school_name,
label: school.school_name,
data: years.map((year) => {
const yearData = data.yearly_data.find((d) => d.year === year);
const yearData = data?.yearly_data.find((d) => d.year === year);
if (!yearData) return null;
return yearData[metric as keyof typeof yearData] as number | null;
}),
borderColor: color,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
pointStyle: POINT_STYLES[index % POINT_STYLES.length],
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
pointHoverRadius: isMobile ? 5 : 6,
tension: 0.3,
spanGaps: true,
};
@@ -52,9 +87,11 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
datasets,
};
// Determine if metric is a progress score or percentage
const isProgressScore = metric.includes('progress');
const isPercentage = metric.includes('pct') || metric.includes('rate');
const kind = metricKind(metric);
const yBounds = computeYBounds(
datasets.flatMap((ds) => ds.data as Array<number | null>),
kind,
);
const options: ChartOptions<'line'> = {
responsive: true,
@@ -65,6 +102,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
},
plugins: {
legend: {
display: !isMobile,
position: 'top' as const,
labels: {
usePointStyle: true,
@@ -74,26 +112,22 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
},
},
},
// No in-chart title: the section heading and metric selector above the
// chart already state the metric.
title: {
display: true,
text: `${metricLabel} - Comparison`,
font: {
size: 16,
weight: 'bold',
},
padding: {
bottom: 20,
},
display: false,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
padding: isMobile ? 10 : 12,
titleFont: {
size: 14,
size: isMobile ? 12 : 14,
},
bodyFont: {
size: 13,
size: isMobile ? 11 : 13,
},
usePointStyle: true,
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
callbacks: {
label: function (context) {
let label = context.dataset.label || '';
@@ -101,13 +135,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
label += ': ';
}
if (context.parsed.y !== null) {
if (isProgressScore) {
label += context.parsed.y.toFixed(1);
} else if (isPercentage) {
label += context.parsed.y.toFixed(1) + '%';
} else {
label += context.parsed.y.toFixed(1);
}
label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : '');
} else {
label += 'N/A';
}
@@ -121,17 +149,18 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
type: 'linear' as const,
display: true,
title: {
display: true,
text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value',
display: !isMobile,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
font: {
size: 12,
weight: 'bold',
},
},
...(isPercentage && {
min: 0,
max: 100,
}),
...yBounds,
ticks: {
font: { size: isMobile ? 10 : 12 },
...(isMobile && { maxTicksLimit: 5 }),
},
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
@@ -141,16 +170,58 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
display: false,
},
title: {
display: true,
display: !isMobile,
text: 'Year',
font: {
size: 12,
weight: 'bold',
},
},
ticks: {
font: { size: isMobile ? 10 : 12 },
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
},
},
},
};
return <Line data={chartData} options={options} />;
const toggleFocus = (urn: number) => {
const next = focusedUrn === urn ? null : urn;
setFocusedUrn(next);
if (next !== null) track('compare_focus_school', { urn: next });
};
return (
<div className={styles.wrapper}>
{/* Mobile legend + focus control; a single series needs no legend. */}
{schools.length > 1 && (
<div className={styles.chips} role="group" aria-label="Highlight a school on the chart">
{schools.map((school, index) => (
<button
key={school.urn}
type="button"
className={styles.chip}
aria-pressed={focusedUrn === school.urn}
onClick={() => toggleFocus(school.urn)}
>
<span
className={styles.chipDot}
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<span
className={styles.chipName}
style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}
>
{school.school_name}
</span>
</button>
))}
</div>
)}
<div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div>
</div>
);
}
@@ -454,7 +454,10 @@
}
.chartContainer {
height: 300px;
/* Taller than desktop's proportion would suggest: the chip legend row
sits inside, and the in-chart title/legend/axis titles are gone, so
nearly all of this is plot area. */
height: 340px;
}
.comparisonTable {
+4 -1
View File
@@ -111,8 +111,10 @@ export function ComparisonView({
setComparisonData(data.comparison);
})
.catch((err) => {
// Keep whatever we already have (SSR data or a previous fetch) rather
// than blanking the chart — a transient refetch failure shouldn't
// destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err);
setComparisonData(null);
});
} else {
setComparisonData(null);
@@ -429,6 +431,7 @@ export function ComparisonView({
<div className={styles.chartContainer}>
<ComparisonChart
comparisonData={activeComparisonData}
schools={activeSchools}
metric={selectedMetric}
metricLabel={metricLabel}
/>
@@ -36,6 +36,91 @@
margin-bottom: 0;
}
.searchHint {
margin: 0.875rem 0 0;
font-size: 0.95rem;
color: var(--text-secondary, #5a554d);
text-align: center;
}
.searchHint strong {
color: var(--text-primary, #1a1612);
font-weight: 600;
}
@media (max-width: 600px) {
.searchHint {
font-size: 0.85rem;
text-align: left;
}
}
.nearMeRow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.nearMeBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.375rem;
background: var(--accent-teal, #2d7d7d);
color: #fff;
border: none;
border-radius: 999px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
font-family: inherit;
}
.nearMeBtn:hover:not(:disabled) {
background: #235f5f;
transform: translateY(-1px);
}
.nearMeBtn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.nearMeSpinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: nearMeSpin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes nearMeSpin {
to {
transform: rotate(360deg);
}
}
.geoError {
font-size: 0.8125rem;
color: var(--accent-coral-dark, #b04a2e);
margin: 0;
max-width: 340px;
text-align: center;
}
@media (max-width: 600px) {
.nearMeBtn {
width: 100%;
justify-content: center;
}
}
.searchSection {
margin-bottom: 0;
}
+61 -3
View File
@@ -11,9 +11,21 @@ interface FilterBarProps {
filters: Filters;
isHero?: boolean;
resultFilters?: ResultFilters;
// Geolocation "use my location" affordance, shown beside the hero search box.
// The state and handler live in HomeView (which owns the geolocation flow).
onNearMe?: () => void;
geoState?: "idle" | "requesting" | "error";
geoError?: string | null;
}
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
export function FilterBar({
filters,
isHero,
resultFilters,
onNearMe,
geoState = "idle",
geoError,
}: FilterBarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -182,6 +194,52 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
{isPending ? <div className={styles.spinner}></div> : "Search"}
</button>
</div>
{isHero && (
<>
<p className={styles.searchHint}>
Search by <strong>school name</strong> or use your{" "}
<strong>postcode</strong> for the nearest schools.
</p>
{onNearMe && (
<div className={styles.nearMeRow}>
<button
type="button"
className={styles.nearMeBtn}
onClick={onNearMe}
disabled={geoState === "requesting"}
>
{geoState === "requesting" ? (
<>
<span className={styles.nearMeSpinner} aria-hidden="true" />
Locating you
</>
) : (
<>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z" />
<circle cx="12" cy="9" r="2.5" />
</svg>
Use my location
</>
)}
</button>
{geoError && (
<p className={styles.geoError} role="alert">
{geoError}
</p>
)}
</div>
)}
</>
)}
</form>
{!isHero && (
@@ -310,8 +368,8 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
disabled={isPending}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form (11-18)</option>
<option value="no">Without sixth form (11-16)</option>
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
</select>
{admissionsPolicyOptions.length > 0 && (
+10 -62
View File
@@ -369,6 +369,16 @@
.viewToggle {
justify-content: center;
flex-shrink: 0;
}
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
& Maths %"), which overflows a phone viewport — beside the view toggle it
ran off the right edge. Let it flex into the remaining space and shrink;
the selected label truncates instead of pushing past the screen. */
.sortSelect {
flex: 1;
min-width: 0;
}
.mapViewContainer {
@@ -496,68 +506,6 @@
}
}
.discoverySection {
padding: 0.5rem 0 0.5rem;
text-align: center;
}
.nearMeRow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.nearMeBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.375rem;
background: var(--accent-teal, #2d7d7d);
color: #fff;
border: none;
border-radius: 999px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
font-family: inherit;
}
.nearMeBtn:hover:not(:disabled) {
background: #235f5f;
transform: translateY(-1px);
}
.nearMeBtn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.nearMeBtnSpinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: nearMeSpin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes nearMeSpin {
to { transform: rotate(360deg); }
}
.geoError {
font-size: 0.8125rem;
color: var(--accent-coral-dark, #b04a2e);
margin: 0;
max-width: 340px;
text-align: center;
}
.quickSearches {
display: flex;
align-items: center;
+3 -29
View File
@@ -284,37 +284,11 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
filters={filters}
isHero={!isSearchActive}
resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
/>
{/* Discovery section shown on landing page before any search */}
{!isSearchActive && initialSchools.schools.length === 0 && (
<div className={styles.discoverySection}>
<div className={styles.nearMeRow}>
<button
className={styles.nearMeBtn}
onClick={handleNearMe}
disabled={geoState === 'requesting'}
>
{geoState === 'requesting' ? (
<>
<span className={styles.nearMeBtnSpinner} aria-hidden="true" />
Locating you
</>
) : (
<>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z"/>
<circle cx="12" cy="9" r="2.5"/>
</svg>
Schools near me
</>
)}
</button>
{geoError && <p className={styles.geoError} role="alert">{geoError}</p>}
</div>
</div>
)}
{/* Admissions countdown strip — only on landing page */}
{!isSearchActive && (
<section className={styles.admissionsStrip}>
+3 -11
View File
@@ -10,12 +10,13 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset } from 'chart.js';
import '@/lib/chartSetup';
import type { SchoolResult } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics';
import styles from './PerformanceChart.module.css';
@@ -68,16 +69,7 @@ export function PerformanceChart({
const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => formatAcademicYear(d.year));
// ── Mobile detection ─────────────────────────────────────────────────
// Hydration-safe: SSR renders desktop; client flips to mobile after mount.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)');
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
const isMobile = useIsMobile();
// ── Build per-year national averages ─────────────────────────────────
const natRefRwm: (number | null)[] = sortedData.map(d => {
@@ -713,18 +713,6 @@
margin: -0.5rem 0 1rem;
}
/* Response count badge */
.responseBadge {
font-size: 0.75rem;
font-weight: 500;
font-family: var(--font-dm-sans), sans-serif;
color: var(--text-muted, #8a847a);
background: var(--bg-secondary, #f3ede4);
padding: 0.1rem 0.5rem;
border-radius: 999px;
margin-left: auto;
}
.subSectionTitle {
font-size: 0.875rem;
font-weight: 600;
@@ -732,18 +720,6 @@
margin: 1.25rem 0 0.75rem;
}
/* Parent recommendation line in Ofsted section */
.parentRecommendLine {
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
margin: 0.5rem 0 0;
}
.parentRecommendLine strong {
color: var(--accent-teal, #2d7d7d);
font-weight: 700;
}
/* Metrics Grid & Cards */
.metricsGrid {
display: grid;
@@ -1094,49 +1070,6 @@
text-decoration: underline;
}
/* Parent View */
.parentViewGrid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.parentViewRow {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.parentViewLabel {
flex: 0 0 18rem;
color: var(--text-secondary, #5c564d);
font-size: 0.8125rem;
}
.parentViewBar {
flex: 1;
height: 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
overflow: hidden;
}
.parentViewFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px;
transition: width 0.4s ease;
}
.parentViewPct {
flex: 0 0 2.75rem;
text-align: right;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* Admissions badge — uses unified status colours */
.admissionsBadge {
display: inline-flex;
@@ -1269,25 +1202,6 @@
}
@media (max-width: 480px) {
.parentViewRow {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.parentViewLabel {
flex: none;
max-width: 100%;
}
.parentViewBar {
width: 100%;
}
.parentViewPct {
flex: none;
}
.card {
padding: 1rem;
}
+2 -51
View File
@@ -13,7 +13,7 @@ import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
import { MetricTooltip } from './MetricTooltip';
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, OfstedParentView, SchoolCensus,
OfstedInspection, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
@@ -63,7 +63,6 @@ interface SchoolDetailViewProps {
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
parentView: OfstedParentView | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
@@ -75,7 +74,7 @@ interface SchoolDetailViewProps {
export function SchoolDetailView({
schoolInfo, yearlyData, absenceData,
ofsted, parentView, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
}: SchoolDetailViewProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
@@ -234,8 +233,6 @@ export function SchoolDetailView({
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
if (parentView && parentView.total_responses != null && parentView.total_responses > 0)
navItems.push({ id: 'parents', label: 'Parents' });
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -549,11 +546,6 @@ export function SchoolDetailView({
) : null;
})}
</div>
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</p>
)}
</>
) : (
/* ── Old OEIF layout ── */
@@ -572,11 +564,6 @@ export function SchoolDetailView({
<p className={styles.ofstedDisclaimer}>
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
</p>
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</p>
)}
{oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
@@ -1129,42 +1116,6 @@ export function SchoolDetailView({
</section>
)}
{/* What Parents Say */}
{parentView && parentView.total_responses != null && parentView.total_responses > 0 && (
<section id="parents" className={styles.card}>
<h2 className={styles.sectionTitle}>
What Parents Say
<span className={styles.responseBadge}>
{parentView.total_responses.toLocaleString()} responses
</span>
</h2>
<p className={styles.sectionSubtitle}>
From the Ofsted Parent View survey parents share their experience of this school.
</p>
<div className={styles.parentViewGrid}>
{[
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
].filter(q => q.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.parentViewRow}>
<span className={styles.parentViewLabel}>{label}</span>
<div className={styles.parentViewBar}>
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
</div>
))}
</div>
</section>
)}
{/* School Life */}
{hasSchoolLife && (
<section id="school-life" className={styles.card}>
@@ -34,6 +34,15 @@
background: #fff;
}
/* Fallback fullscreen (iOS Safari — no Element.requestFullscreen): the API
can't promote the element, so pin it over the page ourselves. Above the
comparison toast (3000) and everything else except modals (9999+). */
.wrapper[data-fs-fallback] {
position: fixed;
inset: 0;
z-index: 5000;
}
.skeleton {
width: 100%;
height: 100%;
+29 -4
View File
@@ -29,25 +29,50 @@ interface SchoolHeroMapProps {
export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>(
function SchoolHeroMap({ lat, lng }, ref) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [nativeFullscreen, setNativeFullscreen] = useState(false);
// iOS Safari has no Element.requestFullscreen — fall back to a
// fixed-position overlay driven by state instead of the Fullscreen API.
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
const isFullscreen = nativeFullscreen || fallbackFullscreen;
const open = useCallback(() => {
wrapperRef.current?.requestFullscreen?.().catch(() => {});
const el = wrapperRef.current;
if (!el) return;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
} else {
setFallbackFullscreen(true);
}
}, []);
const close = useCallback(() => {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
setFallbackFullscreen(false);
}, []);
useImperativeHandle(ref, () => ({ open }), [open]);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
const onChange = () => setNativeFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
// The fallback overlay sits on top of the page rather than replacing it,
// so lock body scroll while it is up.
useEffect(() => {
if (!fallbackFullscreen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [fallbackFullscreen]);
return (
<div ref={wrapperRef} className={styles.wrapper} data-fullscreen={isFullscreen || undefined}>
<div
ref={wrapperRef}
className={styles.wrapper}
data-fullscreen={isFullscreen || undefined}
data-fs-fallback={fallbackFullscreen || undefined}
>
<LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} />
{isFullscreen ? (
@@ -10,6 +10,15 @@
height: 100dvh;
}
/* Fallback fullscreen (iOS Safari — no Element.requestFullscreen): the API
can't promote the element, so pin it over the page ourselves. Above the
comparison toast (3000) and the bottom nav; below modals (9999+). */
.mapWrapper.fsFallback {
position: fixed;
inset: 0;
z-index: 5000;
}
.fullscreenBtn {
position: absolute;
top: 0.625rem;
+38 -8
View File
@@ -33,22 +33,52 @@ interface SchoolMapProps {
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [nativeFullscreen, setNativeFullscreen] = useState(false);
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
// overlay driven by state instead of the Fullscreen API.
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
const isFullscreen = nativeFullscreen || fallbackFullscreen;
// Sync state with browser fullscreen events (e.g. Escape key)
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
const onFsChange = () => setNativeFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
// Lock body scroll while the fallback overlay is up.
useEffect(() => {
if (!fallbackFullscreen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [fallbackFullscreen]);
// Leaflet re-measures on window resize (trackResize). Native fullscreen fires
// one; the CSS fallback overlay changes size without a resize event, so nudge
// Leaflet after the layout settles or the map fills only part of the screen.
useEffect(() => {
const id = requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
return () => cancelAnimationFrame(id);
}, [isFullscreen]);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
wrapperRef.current?.requestFullscreen();
} else {
document.exitFullscreen();
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
return;
}
}, []);
if (fallbackFullscreen) {
setFallbackFullscreen(false);
return;
}
const el = wrapperRef.current;
if (!el) return;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
} else {
setFallbackFullscreen(true);
}
}, [fallbackFullscreen]);
// Calculate center if not provided
const mapCenter: [number, number] = center || (() => {
@@ -64,7 +94,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
})();
return (
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''}`}>
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''} ${fallbackFullscreen ? styles.fsFallback : ''}`}>
<button
className={styles.fullscreenBtn}
onClick={toggleFullscreen}
@@ -383,17 +383,6 @@
margin: 1.25rem 0 0.75rem;
}
.responseBadge {
font-size: 0.75rem;
font-weight: 500;
font-family: var(--font-dm-sans), sans-serif;
color: var(--text-muted, #8a847a);
background: var(--bg-secondary, #f3ede4);
padding: 0.1rem 0.5rem;
border-radius: 999px;
margin-left: auto;
}
/* ── Progress 8 suspension banner ───────────────────── */
.p8Banner {
background: rgba(180, 120, 0, 0.1);
@@ -664,60 +653,6 @@
text-decoration: underline;
}
/* ── Parent View ─────────────────────────────────────── */
.parentRecommendLine {
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
margin: 0.5rem 0 0;
}
.parentRecommendLine strong {
color: var(--accent-teal, #2d7d7d);
font-weight: 700;
}
.parentViewGrid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.parentViewRow {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.parentViewLabel {
flex: 0 0 18rem;
color: var(--text-secondary, #5c564d);
font-size: 0.8125rem;
}
.parentViewBar {
flex: 1;
height: 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
overflow: hidden;
}
.parentViewFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px;
transition: width 0.4s ease;
}
.parentViewPct {
flex: 0 0 2.75rem;
text-align: right;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* ── Admissions ──────────────────────────────────────── */
.admissionsTypeBadge {
border-radius: 6px;
@@ -1135,10 +1070,6 @@
font-size: 1rem;
}
.parentViewLabel {
flex-basis: 10rem;
}
.ofstedReportLink {
margin-left: 0;
display: block;
@@ -1151,25 +1082,6 @@
}
@media (max-width: 480px) {
.parentViewRow {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.parentViewLabel {
flex: none;
max-width: 100%;
}
.parentViewBar {
width: 100%;
}
.parentViewPct {
flex: none;
}
.metricsGrid {
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
@@ -19,7 +19,7 @@ const PerformanceChart = dynamic(
);
import type {
School, SchoolResult, AbsenceData,
OfstedInspection, OfstedParentView, SchoolCensus,
OfstedInspection, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
@@ -65,7 +65,6 @@ interface SecondarySchoolDetailViewProps {
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
ofsted: OfstedInspection | null;
parentView: OfstedParentView | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
senDetail: SenDetail | null;
@@ -76,7 +75,7 @@ interface SecondarySchoolDetailViewProps {
export function SecondarySchoolDetailView({
schoolInfo, yearlyData,
ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData,
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
}: SecondarySchoolDetailViewProps) {
const router = useRouter();
// Hero map — the "View on map" link opens its fullscreen view.
@@ -99,9 +98,9 @@ export function SecondarySchoolDetailView({
const secondaryAvg = nationalAvg?.secondary ?? {};
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasParents = parentView != null && parentView.total_responses != null && parentView.total_responses > 0;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
@@ -159,7 +158,6 @@ export function SecondarySchoolDetailView({
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
if (hasParents) navItems.push({ id: 'parents', label: 'Parents' });
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -435,11 +433,6 @@ export function SecondarySchoolDetailView({
</div>
</>
)}
{hasParents && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView!.q_recommend_pct!)}%</strong> of parents would recommend this school ({parentView!.total_responses!.toLocaleString()} responses)
</p>
)}
</section>
)}
@@ -775,42 +768,6 @@ export function SecondarySchoolDetailView({
</details>
</section>
)}
{/* ── Parent View ────────────────────────────────── */}
{hasParents && parentView && (
<section id="parents" className={styles.card}>
<h2 className={styles.sectionTitle}>
What Parents Say
<span className={styles.responseBadge}>
{parentView.total_responses!.toLocaleString()} responses
</span>
</h2>
<p className={styles.sectionSubtitle}>
From the Ofsted Parent View survey parents share their experience of this school.
</p>
<div className={styles.parentViewGrid}>
{[
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
].filter(q => q.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.parentViewRow}>
<span className={styles.parentViewLabel}>{label}</span>
<div className={styles.parentViewBar}>
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
</div>
))}
</div>
</section>
)}
{/* ── Wellbeing ──────────────────────────────────── */}
{hasWellbeing && (
<section id="wellbeing" className={styles.card}>
+2 -1
View File
@@ -23,7 +23,8 @@ function detectAdmissionsTag(school: School): string | null {
}
function hasSixthForm(school: School): boolean {
return school.age_range?.includes('18') ?? false;
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
return school.has_sixth_form ?? false;
}
interface SecondarySchoolRowProps {
+23
View File
@@ -0,0 +1,23 @@
/**
* Viewport hook shared by the chart components.
* Hydration-safe: SSR and the first client render report desktop; the
* media-query subscription flips the value after mount.
*/
'use client';
import { useEffect, useState } from 'react';
export function useIsMobile(maxWidth = 640): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${maxWidth}px)`);
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [maxWidth]);
return isMobile;
}
+1
View File
@@ -29,6 +29,7 @@ export type EventName =
| 'compare_viewed'
| 'compare_metric_changed'
| 'compare_shared'
| 'compare_focus_school'
// Operational
| 'api_error'
| 'results_load_more';
+1 -20
View File
@@ -17,6 +17,7 @@ export interface School {
school_type_code: string | null;
religious_denomination: string | null;
age_range: string | null;
has_sixth_form?: boolean | null;
// Address
address1: string | null;
@@ -99,25 +100,6 @@ export interface OfstedInspection {
rc_sixth_form: number | null;
}
export interface OfstedParentView {
survey_date: string | null;
total_responses: number | null;
q_happy_pct: number | null;
q_safe_pct: number | null;
q_behaviour_pct: number | null;
q_bullying_pct: number | null;
q_communication_pct: number | null;
q_progress_pct: number | null;
q_teaching_pct: number | null;
q_information_pct: number | null;
q_curriculum_pct: number | null;
q_future_pct: number | null;
q_leadership_pct: number | null;
q_wellbeing_pct: number | null;
q_recommend_pct: number | null;
q_sen_pct: number | null;
}
export interface SchoolCensus {
year: number;
total_pupils: number | null;
@@ -312,7 +294,6 @@ export interface SchoolDetailsResponse {
absence_data: AbsenceData | null;
// Supplementary data (null until Kestra populates)
ofsted: OfstedInspection | null;
parent_view: OfstedParentView | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */
+51
View File
@@ -317,6 +317,57 @@ export function getTrendColor(trend: 'up' | 'down' | 'stable'): string {
}
}
/**
* Broad shape of a KS2/KS4 metric, used to scale chart axes and format values.
*/
export type MetricKind = 'percentage' | 'progress' | 'score';
export function metricKind(metric: string): MetricKind {
if (metric.includes('progress')) return 'progress';
if (metric.includes('pct') || metric.includes('rate')) return 'percentage';
return 'score';
}
/**
* Fit a chart y-axis to the data instead of a fixed frame, so clustered
* series remain distinguishable. Padding keeps a minimum span so noise is
* not magnified into drama.
*
* - percentage: pad and snap to 5s; cap at 100; floor at 0 only when the
* data is non-negative (some trend metrics have `pct` in the key but hold
* negative year-over-year deltas).
* - progress: symmetric around 0 so the zero line always shows.
* - score (Attainment 8, scaled scores): pad and snap to integers; floor at
* 0 only when the data is non-negative.
*/
export function computeYBounds(
values: Array<number | null | undefined>,
kind: MetricKind,
): { min?: number; max?: number } {
const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
if (nums.length === 0) return {};
const lo = Math.min(...nums);
const hi = Math.max(...nums);
if (kind === 'progress') {
const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5));
return { min: -reach, max: reach };
}
if (kind === 'percentage') {
const pad = Math.max(5, Math.round((hi - lo) * 0.2));
const min = Math.floor((lo - pad) / 5) * 5;
const max = Math.min(100, Math.ceil((hi + pad) / 5) * 5);
return { min: lo >= 0 ? Math.max(0, min) : min, max };
}
// score
const pad = Math.max(2, (hi - lo) * 0.2);
const min = Math.floor(lo - pad);
return { min: lo >= 0 ? Math.max(0, min) : min, max: Math.ceil(hi + pad) };
}
// ============================================================================
// Local Storage Utilities
// ============================================================================
+4 -15
View File
@@ -3,21 +3,10 @@ const nextConfig = {
// Enable standalone output for Docker
output: 'standalone',
// API Proxy to FastAPI backend
async rewrites() {
const apiUrl = process.env.FASTAPI_URL || 'http://localhost:8000/api';
const backendUrl = apiUrl.replace(/\/api$/, '');
return [
{
source: '/api/:path*',
destination: `${apiUrl}/:path*`,
},
{
source: '/sitemap.xml',
destination: `${backendUrl}/sitemap.xml`,
},
];
},
// The /api/* and /sitemap.xml proxies to the FastAPI backend are route
// handlers (app/api/[...path]/route.ts, app/sitemap.xml/route.ts) rather
// than rewrites, so the backend host is read from FASTAPI_URL at runtime
// instead of being baked into the build.
// Image optimization
images: {
-1
View File
@@ -19,7 +19,6 @@ RUN pip install --no-cache-dir \
./plugins/extractors/tap-uk-gias \
./plugins/extractors/tap-uk-ees \
./plugins/extractors/tap-uk-ofsted \
./plugins/extractors/tap-uk-parent-view \
./plugins/extractors/tap-uk-fbit \
./plugins/extractors/tap-uk-idaci
-25
View File
@@ -156,31 +156,6 @@ with DAG(
extract_ees_group >> dbt_build_ees >> sync_typesense_ees
# ── Monthly DAG (Parent View) ──────────────────────────────────────────
with DAG(
dag_id="school_data_monthly_parent_view",
default_args=default_args,
description="Monthly Ofsted Parent View extraction and transform",
schedule="0 3 1 * *",
start_date=datetime(2025, 1, 1),
catchup=False,
tags=["school-compare", "monthly"],
) as monthly_parent_view_dag:
extract_parent_view = BashOperator(
task_id="extract_parent_view",
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-parent-view target-postgres",
)
dbt_build_parent_view = BashOperator(
task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_parent_view+ fact_parent_view+",
)
extract_parent_view >> dbt_build_parent_view
# ── Annual DAG (IDACI Deprivation) ────────────────────────────────────
with DAG(
-5
View File
@@ -50,11 +50,6 @@ plugins:
kind: string
description: Ofsted Management Information download URL
- name: tap-uk-parent-view
namespace: uk_parent_view
pip_url: ./plugins/extractors/tap-uk-parent-view
executable: tap-uk-parent-view
- name: tap-uk-fbit
namespace: uk_fbit
pip_url: ./plugins/extractors/tap-uk-fbit
@@ -33,6 +33,7 @@ class GIASEstablishmentsStream(Stream):
th.Property("EstablishmentName", th.StringType),
th.Property("TypeOfEstablishment (name)", th.StringType),
th.Property("PhaseOfEducation (name)", th.StringType),
th.Property("OfficialSixthForm (name)", th.StringType),
th.Property("LA (code)", th.StringType),
th.Property("LA (name)", th.StringType),
th.Property("EstablishmentNumber", th.StringType),
@@ -1,18 +0,0 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "tap-uk-parent-view"
version = "0.1.0"
description = "Singer tap for UK Ofsted Parent View survey data"
requires-python = ">=3.10"
dependencies = [
"singer-sdk~=0.53",
"requests>=2.31",
"pandas>=2.0",
"openpyxl>=3.1",
]
[project.scripts]
tap-uk-parent-view = "tap_uk_parent_view.tap:TapUKParentView.cli"
@@ -1 +0,0 @@
"""tap-uk-parent-view: Singer tap for Ofsted Parent View survey data."""
@@ -1,151 +0,0 @@
"""Parent View Singer tap — extracts survey data from Ofsted Parent View open data portal."""
from __future__ import annotations
import io
import re
from datetime import date
import pandas as pd
import requests
from singer_sdk import Stream, Tap
from singer_sdk import typing as th
OPEN_DATA_PAGE = "https://parentview.ofsted.gov.uk/open-data"
def _positive_pct(row: pd.Series, q_col_base: str) -> float | None:
"""Sum 'Strongly agree' + 'Agree' percentages for a question."""
strongly = row.get(f"{q_col_base} - Strongly agree %") or row.get(f"{q_col_base} - Strongly Agree %")
agree = row.get(f"{q_col_base} - Agree %")
try:
total = 0.0
if pd.notna(strongly):
total += float(strongly)
if pd.notna(agree):
total += float(agree)
return round(total, 1) if total > 0 else None
except (TypeError, ValueError):
return None
class ParentViewStream(Stream):
"""Stream: Parent View survey responses per school."""
name = "parent_view"
primary_keys = ["urn"]
replication_key = None
schema = th.PropertiesList(
th.Property("urn", th.IntegerType, required=True),
th.Property("survey_date", th.StringType),
th.Property("total_responses", th.IntegerType),
th.Property("q_happy_pct", th.NumberType),
th.Property("q_safe_pct", th.NumberType),
th.Property("q_behaviour_pct", th.NumberType),
th.Property("q_bullying_pct", th.NumberType),
th.Property("q_communication_pct", th.NumberType),
th.Property("q_progress_pct", th.NumberType),
th.Property("q_teaching_pct", th.NumberType),
th.Property("q_information_pct", th.NumberType),
th.Property("q_curriculum_pct", th.NumberType),
th.Property("q_future_pct", th.NumberType),
th.Property("q_leadership_pct", th.NumberType),
th.Property("q_wellbeing_pct", th.NumberType),
th.Property("q_recommend_pct", th.NumberType),
).to_dict()
def _discover_download_url(self) -> str:
"""Scrape the open data page for the download link."""
resp = requests.get(OPEN_DATA_PAGE, timeout=30)
resp.raise_for_status()
urls = re.findall(r'href="([^"]+\.(?:xlsx|csv|zip))"', resp.text, re.IGNORECASE)
if not urls:
msg = "No download link found on Parent View open data page"
raise RuntimeError(msg)
url = urls[0]
if not url.startswith("http"):
url = "https://parentview.ofsted.gov.uk" + url
return url
def get_records(self, context):
url = self._discover_download_url()
self.logger.info("Downloading Parent View data: %s", url)
resp = requests.get(url, timeout=120)
resp.raise_for_status()
if url.endswith(".xlsx"):
df = pd.read_excel(io.BytesIO(resp.content))
else:
df = pd.read_csv(
io.BytesIO(resp.content),
encoding="latin-1",
low_memory=False,
)
# Normalise URN column
urn_col = next((c for c in df.columns if c.strip().upper() == "URN"), None)
if not urn_col:
self.logger.error("URN column not found. Columns: %s", list(df.columns)[:20])
return
df.rename(columns={urn_col: "urn"}, inplace=True)
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
# Find total responses column
resp_col = next(
(c for c in df.columns if "total" in c.lower() and "respon" in c.lower()),
None,
)
today = date.today().isoformat()
for _, row in df.iterrows():
try:
urn = int(row["urn"])
except (ValueError, TypeError):
continue
total = None
if resp_col and pd.notna(row.get(resp_col)):
try:
total = int(row[resp_col])
except (ValueError, TypeError):
pass
yield {
"urn": urn,
"survey_date": today,
"total_responses": total,
"q_happy_pct": _positive_pct(row, "Q1"),
"q_safe_pct": _positive_pct(row, "Q2"),
"q_behaviour_pct": _positive_pct(row, "Q3"),
"q_bullying_pct": _positive_pct(row, "Q4"),
"q_communication_pct": _positive_pct(row, "Q5"),
"q_progress_pct": _positive_pct(row, "Q7"),
"q_teaching_pct": _positive_pct(row, "Q8"),
"q_information_pct": _positive_pct(row, "Q9"),
"q_curriculum_pct": _positive_pct(row, "Q10"),
"q_future_pct": _positive_pct(row, "Q11"),
"q_leadership_pct": _positive_pct(row, "Q12"),
"q_wellbeing_pct": _positive_pct(row, "Q13"),
"q_recommend_pct": _positive_pct(row, "Q14"),
}
class TapUKParentView(Tap):
"""Singer tap for UK Ofsted Parent View."""
name = "tap-uk-parent-view"
config_jsonschema = th.PropertiesList(
th.Property("download_url", th.StringType, description="Direct URL to Parent View data file"),
).to_dict()
def discover_streams(self):
return [ParentViewStream(self)]
if __name__ == "__main__":
TapUKParentView.cli()
@@ -16,6 +16,17 @@ models:
tests:
- not_null:
severity: warn
- 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]
- name: status
tests:
- accepted_values:
@@ -105,12 +116,6 @@ models:
- name: year
tests: [not_null]
- name: fact_parent_view
description: Parent View survey responses
columns:
- name: urn
tests: [not_null]
- name: fact_ks2_national_averages
description: Official DfE KS2 national headline averages — one row per academic year
columns:
@@ -52,6 +52,17 @@ select
s.religious_character,
s.gender,
s.statutory_low_age || '-' || s.statutory_high_age as age_range,
-- 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.
-- lower(trim()) guards against casing/whitespace variants in raw GIAS
-- data, same as the phase derivation above — an unmatched variant would
-- otherwise silently fall through to the age-range fallback.
case
when lower(trim(s.official_sixth_form)) = 'has a sixth form' then true
when lower(trim(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,
s.capacity,
s.total_pupils,
concat_ws(' ', s.head_title, s.head_first_name, s.head_last_name) as headteacher_name,
@@ -1,20 +0,0 @@
-- Mart: Parent View survey responses — one row per URN (latest survey)
select
urn,
survey_date,
total_responses,
q_happy_pct,
q_safe_pct,
q_behaviour_pct,
q_bullying_pct,
q_communication_pct,
q_progress_pct,
q_teaching_pct,
q_information_pct,
q_curriculum_pct,
q_future_pct,
q_leadership_pct,
q_wellbeing_pct,
q_recommend_pct
from {{ ref('stg_parent_view') }}
@@ -53,9 +53,6 @@ sources:
# Phonics: no school-level data on EES (only national/LA level)
- name: parent_view
description: Ofsted Parent View survey responses
- name: fbit_finance
description: Financial benchmarking data from FBIT API
@@ -14,6 +14,7 @@ renamed as (
"EstablishmentName" as school_name,
"TypeOfEstablishment (name)" as school_type,
"PhaseOfEducation (name)" as phase,
nullif(trim("OfficialSixthForm (name)"), '') as official_sixth_form,
"Gender (name)" as gender,
"ReligiousCharacter (name)" as religious_character,
"AdmissionsPolicy (name)" as admissions_policy,
@@ -1,30 +0,0 @@
-- Staging model: Ofsted Parent View survey responses
-- The tap computes positive percentages (Strongly agree + Agree) per question.
with source as (
select * from {{ source('raw', 'parent_view') }}
),
renamed as (
select
cast(urn as integer) as urn,
cast(survey_date as date) as survey_date,
cast(total_responses as integer) as total_responses,
cast(q_happy_pct as numeric) as q_happy_pct,
cast(q_safe_pct as numeric) as q_safe_pct,
cast(q_behaviour_pct as numeric) as q_behaviour_pct,
cast(q_bullying_pct as numeric) as q_bullying_pct,
cast(q_communication_pct as numeric) as q_communication_pct,
cast(q_progress_pct as numeric) as q_progress_pct,
cast(q_teaching_pct as numeric) as q_teaching_pct,
cast(q_information_pct as numeric) as q_information_pct,
cast(q_curriculum_pct as numeric) as q_curriculum_pct,
cast(q_future_pct as numeric) as q_future_pct,
cast(q_leadership_pct as numeric) as q_leadership_pct,
cast(q_wellbeing_pct as numeric) as q_wellbeing_pct,
cast(q_recommend_pct as numeric) as q_recommend_pct
from source
where urn is not null
)
select * from renamed
+5
View File
@@ -0,0 +1,5 @@
-- Retire the Ofsted Parent View feature (schema v6).
-- The marts schema is dbt-owned; deleting the dbt model stops the table being
-- rebuilt but does not drop the existing relation, so apply this directly
-- against the staging and production marts databases.
DROP TABLE IF EXISTS marts.fact_parent_view CASCADE;