Compare commits

..
Author SHA1 Message Date
TudorandClaude Fable 5 45ab479062 feat(ui): mark proposed-to-close schools in listings and detail pages
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m46s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 21s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 35s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m1s
Amber tag in listing rows (option A) and a slim notice strip under the
detail-page header (option E): proposed for closure, formal process not
necessarily started, check with the local authority before applying.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:42:11 +01:00
tudor 47335fcda0 Merge pull request 'fix(frontend): proxy /api and /sitemap.xml at runtime, not via baked rewrites' (#14) from fix/runtime-api-proxy into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 48s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 52s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #14
2026-07-06 10:11:39 +00:00
38 changed files with 1639 additions and 130 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 -8
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"]
@@ -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,8 @@ async def get_school_details(request: Request, urn: int):
"address": latest.get("address", ""),
"religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""),
"has_sixth_form": latest.get("has_sixth_form"),
"status": latest.get("status"),
"latitude": latest.get("latitude"),
"longitude": latest.get("longitude"),
"phase": latest.get("phase"),
@@ -601,7 +615,11 @@ 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"),
+30
View File
@@ -3,11 +3,14 @@ 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
@@ -125,6 +128,8 @@ _MAIN_QUERY = text("""
s.religious_character AS religious_denomination,
s.gender,
s.age_range,
s.has_sixth_form,
s.status,
s.admissions_policy,
s.capacity,
s.total_pupils AS gias_total_pupils,
@@ -214,11 +219,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()
+1
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))
+2
View File
@@ -543,6 +543,8 @@ SCHOOL_COLUMNS = [
"postcode",
"religious_denomination",
"age_range",
"has_sixth_form",
"status",
"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"
+70
View File
@@ -0,0 +1,70 @@
"""Tests for GIAS establishment status exposure.
"Open, but proposed to close" schools are now kept by the dims; the API must
surface `status` on list items and school_info so the UI can render the
proposed-to-close marker (listing tag) and notice strip (detail page).
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
PROPOSED = "Open, but proposed to close"
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"age_range": "11-16",
"admissions_policy": None,
"has_sixth_form": False,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 800,
"rwm_expected_pct": np.nan,
"attainment_8_score": 48.0,
}
return pd.DataFrame(
[
{**base, "urn": 200001, "school_name": "Alpha Academy",
"status": "Open"},
{**base, "urn": 200002, "school_name": "Sarson High School",
"status": PROPOSED},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def test_list_payload_includes_status(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[200001]["status"] == "Open"
assert by_urn[200002]["status"] == PROPOSED
def test_detail_payload_includes_status(client):
resp = client.get("/api/schools/200002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["status"] == PROPOSED
+173
View File
@@ -0,0 +1,173 @@
"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).
The filter and payloads must use dim_school.has_sixth_form, not the old
age_range-contains-"18" substring heuristic. The key regression case is a
16-19 sixth-form college: flag true, but "16-19" contains no "18".
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
"""Latest-year snapshot rows as produced by load_latest_school_data."""
base = {
"local_authority": "Testshire",
"school_type": "Academy",
"phase": "Secondary",
"address": "1 Test Street",
"town": "Testtown",
"postcode": "TS1 1AA",
"religious_denomination": None,
"gender": "Mixed",
"admissions_policy": None,
"ofsted_grade": np.nan,
"ofsted_date": None,
"ofsted_framework": None,
"latitude": 51.5,
"longitude": -0.1,
"year": 202425,
"total_pupils": 1000,
"rwm_expected_pct": np.nan,
"attainment_8_score": 50.0,
}
return pd.DataFrame(
[
# 11-18 school WITH a registered sixth form
{**base, "urn": 100001, "school_name": "Alpha High",
"age_range": "11-18", "has_sixth_form": True},
# 16-19 college: old heuristic said NO ("16-19" has no "18"),
# GIAS flag says YES — must appear in the yes-filter results
{**base, "urn": 100002, "school_name": "Beta Sixth Form College",
"age_range": "16-19", "has_sixth_form": True},
# 11-18 age range on paper but NO registered sixth form:
# old heuristic said YES, GIAS flag says NO
{**base, "urn": 100003, "school_name": "Gamma Academy",
"age_range": "11-18", "has_sixth_form": False},
# Missing flag (pipeline not yet re-run) — must not crash,
# must not match the yes-filter
{**base, "urn": 100004, "school_name": "Delta School",
"age_range": "11-16", "has_sixth_form": None},
]
)
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
return TestClient(app_module.app, raise_server_exceptions=False)
def _urns(resp):
return sorted(s["urn"] for s in resp.json()["schools"])
def test_filter_yes_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=yes")
assert resp.status_code == 200, resp.text
# 16-19 college included; 11-18-without-sixth-form excluded
assert _urns(resp) == [100001, 100002]
def test_filter_no_uses_flag_not_age_range(client):
resp = client.get("/api/schools?has_sixth_form=no")
assert resp.status_code == 200, resp.text
# Gamma (flag false) and Delta (flag missing => not true)
assert _urns(resp) == [100003, 100004]
def test_list_payload_includes_flag(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
assert by_urn[100002]["has_sixth_form"] is True
assert by_urn[100003]["has_sixth_form"] is False
assert by_urn[100004]["has_sixth_form"] is None
def test_detail_payload_includes_flag(client):
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
def test_detail_payload_serializes_numpy_bool(monkeypatch):
"""Once the pipeline has run, has_sixth_form is a real bool dtype column
(dbt not_null test guarantees no NULLs), so row access yields
numpy.bool_ rather than a Python bool. convert_to_native must handle it —
otherwise FastAPI's jsonable_encoder raises ValueError and the detail
endpoint 500s (C2)."""
from backend import app as app_module
df = _schools_df()
# Drop the row with a None flag — this fixture models the post-pipeline
# state where the column is a genuine, fully-populated bool dtype.
df = df[df["has_sixth_form"].notna()].reset_index(drop=True)
df["has_sixth_form"] = df["has_sixth_form"].astype(bool)
assert df["has_sixth_form"].dtype == bool
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
client = TestClient(app_module.app, raise_server_exceptions=False)
resp = client.get("/api/schools/100002")
assert resp.status_code == 200, resp.text
assert resp.json()["school_info"]["has_sixth_form"] is True
def test_load_school_data_survives_missing_has_sixth_form_column(monkeypatch):
"""Real prod state until the nightly pipeline first rebuilds the mart:
marts.dim_school lacks has_sixth_form entirely. The first query raises
UndefinedColumn; load_school_data_as_dataframe must retry without the
column (synthesizing it as None) rather than swallow the error and
return (and then have load_school_data cache) an empty DataFrame (C1)."""
import sqlalchemy.exc
from backend import data_loader
data_loader._df_cache = None
data_loader._df_latest_cache = None
good_df = pd.DataFrame(
[
{
"urn": 1,
"school_name": "Fallback School",
"school_type": "Academy",
"has_sixth_form": None,
}
]
)
calls = []
def fake_read_sql(query, con):
calls.append(query)
if len(calls) == 1:
raise sqlalchemy.exc.ProgrammingError(
"SELECT ...",
None,
Exception(
"(psycopg2.errors.UndefinedColumn) column s.has_sixth_form "
"does not exist"
),
)
return good_df.copy()
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
try:
df = data_loader.load_school_data_as_dataframe()
finally:
data_loader._df_cache = None
data_loader._df_latest_cache = None
assert len(calls) == 2, "must retry with the no-sixth-form query variant"
assert calls[1] is data_loader._MAIN_QUERY_NO_SIXTH_FORM
assert not df.empty
assert "has_sixth_form" in df.columns
assert df["has_sixth_form"].iloc[0] is None
+2
View File
@@ -11,6 +11,8 @@ def convert_to_native(value: Any) -> Any:
"""Convert numpy types to native Python types for JSON serialization."""
if pd.isna(value):
return None
if isinstance(value, np.bool_):
return bool(value)
if isinstance(value, (np.integer,)):
return int(value)
if isinstance(value, (np.floating,)):
@@ -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.
+72 -4
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,34 @@ 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.
@@ -75,6 +113,31 @@ test('school hero map opens fullscreen on mobile without the Fullscreen API', as
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');
@@ -100,15 +163,20 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
);
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
expect(urns.length).toBeGreaterThanOrEqual(2);
// Compare three schools, not two: a "primary" search can return all-through
// schools that classify as secondary, and the chips only appear for the
// active phase. With three schools across two phases, the auto-selected
// majority phase always holds ≥2, so the chip legend is guaranteed to render.
expect(urns.length).toBeGreaterThanOrEqual(3);
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
// The mobile chart legend renders one chip per school inside the chart card.
// 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).toHaveCount(2);
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();
@@ -0,0 +1,67 @@
/**
* SecondarySchoolRow — sixth-form tag must come from the GIAS
* has_sixth_form flag, not the age_range-contains-"18" heuristic.
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { SecondarySchoolRow } from '@/components/SecondarySchoolRow';
import type { School } from '@/lib/types';
const base = {
urn: 100002,
school_name: 'Beta Sixth Form College',
local_authority: 'Testshire',
school_type: 'Academy',
phase: 'Secondary',
gender: 'Mixed',
attainment_8_score: 50.0,
} as unknown as School;
describe('SecondarySchoolRow sixth-form tag', () => {
it('shows the tag for a 16-19 college with the GIAS flag set', () => {
render(
<SecondarySchoolRow
school={{ ...base, age_range: '16-19', has_sixth_form: true }}
/>,
);
expect(screen.getByText('Sixth form')).toBeInTheDocument();
});
it('hides the tag for an 11-18 school without a registered sixth form', () => {
render(
<SecondarySchoolRow
school={{ ...base, age_range: '11-18', has_sixth_form: false }}
/>,
);
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
});
it('hides the tag when the flag is missing (pipeline not yet re-run)', () => {
render(
<SecondarySchoolRow school={{ ...base, age_range: '11-18' }} />,
);
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
});
});
describe('SecondarySchoolRow proposed-to-close tag', () => {
it('shows the tag when GIAS status is "Open, but proposed to close"', () => {
render(
<SecondarySchoolRow
school={{ ...base, status: 'Open, but proposed to close' }}
/>,
);
expect(screen.getByText(/Proposed to close/)).toBeInTheDocument();
});
it('hides the tag for a plain open school', () => {
render(<SecondarySchoolRow school={{ ...base, status: 'Open' }} />);
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
});
it('hides the tag when status is missing', () => {
render(<SecondarySchoolRow school={base} />);
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
});
});
+11
View File
@@ -212,3 +212,14 @@ describe('computeYBounds', () => {
expect(computeYBounds([], 'progress')).toEqual({});
});
});
describe('isProposedToClose', () => {
const { isProposedToClose } = require('@/lib/utils');
it('is true only for the exact GIAS proposed-to-close status', () => {
expect(isProposedToClose({ status: 'Open, but proposed to close' })).toBe(true);
expect(isProposedToClose({ status: 'Open' })).toBe(false);
expect(isProposedToClose({ status: null })).toBe(false);
expect(isProposedToClose({})).toBe(false);
});
});
@@ -21,8 +21,10 @@
@media (max-width: 640px) {
.chips {
display: flex;
flex-wrap: wrap;
/* 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;
}
@@ -31,8 +33,8 @@
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
max-width: 100%;
min-height: 40px;
min-width: 0;
padding: 4px 10px;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 999px;
@@ -58,6 +60,8 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 9rem;
/* min-width:0 lets the name shrink inside the grid cell so the
ellipsis kicks in instead of overflowing. */
min-width: 0;
}
}
@@ -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}>
@@ -1543,3 +1543,18 @@
.historyDisclosure[open] > .historyToggle::before {
transform: rotate(90deg);
}
/* GIAS "Open, but proposed to close" notice strip */
.closingStrip {
background: #fdf6e3;
border-left: 4px solid #e2c96f;
border-radius: 0 6px 6px 0;
padding: 0.55rem 0.9rem;
margin: 0.5rem 0;
font-size: 0.88rem;
color: #6e5a00;
max-width: 68ch;
}
.closingStrip strong {
color: #8a6200;
}
+8 -1
View File
@@ -18,7 +18,7 @@ import type {
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import {
formatPercentage, formatProgress, formatAcademicYear,
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose,
} from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
@@ -313,6 +313,13 @@ export function SchoolDetailView({
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)}
</div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
although a formal closure process has not necessarily started. Check with the
local authority before applying.
</div>
)}
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
@@ -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}
@@ -254,3 +254,10 @@
justify-content: center;
}
}
/* GIAS "Open, but proposed to close" marker */
.attrClosing {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
}
+4 -1
View File
@@ -9,7 +9,7 @@
*/
import type { School } from '@/lib/types';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange } from '@/lib/utils';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils';
import styles from './SchoolRow.module.css';
interface SchoolRowProps {
@@ -78,6 +78,9 @@ export function SchoolRow({
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
{showGender && <span className={styles.attr}>{school.gender}</span>}
{isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}> Proposed to close</span>
)}
</div>
{/* Line 3: Key stats */}
@@ -1099,3 +1099,18 @@
padding: 0.75rem;
}
}
/* GIAS "Open, but proposed to close" notice strip */
.closingStrip {
background: #fdf6e3;
border-left: 4px solid #e2c96f;
border-radius: 0 6px 6px 0;
padding: 0.55rem 0.9rem;
margin: 0.5rem 0;
font-size: 0.88rem;
color: #6e5a00;
max-width: 68ch;
}
.closingStrip strong {
color: #8a6200;
}
@@ -23,7 +23,7 @@ import type {
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange } from '@/lib/utils';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose } from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css';
@@ -98,7 +98,8 @@ 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 hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
@@ -236,6 +237,13 @@ export function SecondarySchoolDetailView({
</span>
)}
</div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
although a formal closure process has not necessarily started. Check with the
local authority before applying.
</div>
)}
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
@@ -266,3 +266,9 @@
justify-content: center;
}
}
.closingTag {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
}
+6 -2
View File
@@ -11,7 +11,7 @@
'use client';
import type { School } from '@/lib/types';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange } from '@/lib/utils';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils';
import styles from './SecondarySchoolRow.module.css';
function detectAdmissionsTag(school: School): string | null {
@@ -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 {
@@ -96,6 +97,9 @@ export function SecondarySchoolRow({
{admissionsTag}
</span>
)}
{isProposedToClose(school) && (
<span className={`${styles.provisionTag} ${styles.closingTag}`}> Proposed to close</span>
)}
</div>
{/* Line 3: KS4 stats */}
+2
View File
@@ -17,6 +17,8 @@ export interface School {
school_type_code: string | null;
religious_denomination: string | null;
age_range: string | null;
has_sixth_form?: boolean | null;
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
// Address
address1: string | null;
+15
View File
@@ -718,3 +718,18 @@ export function buildOfstedListBadge(school: {
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
}
// ============================================================================
// Establishment status
// ============================================================================
export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
/**
* GIAS lists some operating schools as "Open, but proposed to close".
* They remain open (and may stay open if the proposal is withdrawn), but the
* UI marks them so families check with the local authority before applying.
*/
export function isProposedToClose(school: { status?: string | null }): boolean {
return school.status === PROPOSED_TO_CLOSE_STATUS;
}
@@ -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),
@@ -16,10 +16,21 @@ 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:
values: ["Open"]
values: ["Open", "Open, but proposed to close"]
- name: dim_location
description: School location dimension with PostGIS geometry
@@ -31,4 +31,5 @@ select
else null
end as longitude
from {{ ref('stg_gias_establishments') }} s
where s.status = 'Open'
-- Must match dim_school's status filter exactly (the API inner-joins the two).
where s.status in ('Open', 'Open, but proposed to close')
+15 -1
View File
@@ -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,
@@ -80,4 +91,7 @@ from schools s
{% if ofsted_relation is not none %}
left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn
{% endif %}
where s.status = 'Open'
-- "Open, but proposed to close" schools are still operating (pupils enrolled,
-- results published) — include them; they drop out automatically once GIAS
-- flips them to "Closed" (marts are fully rebuilt each run).
where s.status in ('Open', 'Open, but proposed to close')
@@ -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,