Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4522cbf645 | ||
|
|
7370712888 | ||
|
|
45ab479062 | ||
|
|
6f602f4a9e | ||
|
|
de81e9cdbd | ||
|
|
45c68b60b4 | ||
|
|
f1388ff5bd | ||
|
|
4d226fd616 | ||
|
|
a524cdc591 | ||
|
|
3fcb1340d4 | ||
|
|
0934c8f38c | ||
|
|
1d149ffc48 | ||
|
|
d11faefebd | ||
|
|
3b35849bb3 | ||
|
|
87f4c6dd40 | ||
|
|
0309b27c84 | ||
|
|
85484a80c4 | ||
|
|
536832a524 | ||
|
|
87642b7b06 | ||
|
|
929748d014 | ||
|
|
4e8df006d7 | ||
|
|
1f8284adfc | ||
|
|
b2dc4d0779 | ||
|
|
1cdcd85e41 | ||
|
|
a00cbe9161 | ||
|
|
64121592fd | ||
|
|
6828f6cd44 | ||
|
|
331ae8d89f | ||
|
|
3adea73ee0 | ||
|
|
47335fcda0 |
@@ -51,11 +51,14 @@ jobs:
|
|||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt pytest "httpx<0.28"
|
||||||
|
|
||||||
- name: Import smoke test
|
- name: Import smoke test
|
||||||
run: python -c "from backend.app import app; print('backend imports OK')"
|
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:
|
build-backend:
|
||||||
name: Build Backend (no push)
|
name: Build Backend (no push)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
venv
|
venv
|
||||||
backend/__pycache__
|
__pycache__/
|
||||||
|
|||||||
+26
-8
@@ -33,7 +33,7 @@ from .data_loader import (
|
|||||||
)
|
)
|
||||||
from .data_loader import get_data_info as get_db_info
|
from .data_loader import get_data_info as get_db_info
|
||||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
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)
|
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
||||||
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
|
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()]
|
df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
|
||||||
if admissions_policy:
|
if admissions_policy:
|
||||||
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
|
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
|
||||||
if has_sixth_form == "yes":
|
# GIAS OfficialSixthForm flag (dim_school.has_sixth_form). NULL (flag not
|
||||||
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
|
# yet populated by the pipeline) is treated as "no sixth form".
|
||||||
elif has_sixth_form == "no":
|
if has_sixth_form in ("yes", "no"):
|
||||||
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
|
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
|
# Include key result metrics for display on cards
|
||||||
location_cols = ["latitude", "longitude"]
|
location_cols = ["latitude", "longitude"]
|
||||||
@@ -582,8 +589,13 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
# Schools with no performance rows (post-16 institutions, PRUs, new
|
||||||
"school_info": {
|
# 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,
|
"urn": urn,
|
||||||
"school_name": latest.get("school_name", ""),
|
"school_name": latest.get("school_name", ""),
|
||||||
"local_authority": latest.get("local_authority", ""),
|
"local_authority": latest.get("local_authority", ""),
|
||||||
@@ -591,6 +603,8 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"address": latest.get("address", ""),
|
"address": latest.get("address", ""),
|
||||||
"religious_denomination": latest.get("religious_denomination", ""),
|
"religious_denomination": latest.get("religious_denomination", ""),
|
||||||
"age_range": latest.get("age_range", ""),
|
"age_range": latest.get("age_range", ""),
|
||||||
|
"has_sixth_form": latest.get("has_sixth_form"),
|
||||||
|
"status": latest.get("status"),
|
||||||
"latitude": latest.get("latitude"),
|
"latitude": latest.get("latitude"),
|
||||||
"longitude": latest.get("longitude"),
|
"longitude": latest.get("longitude"),
|
||||||
"phase": latest.get("phase"),
|
"phase": latest.get("phase"),
|
||||||
@@ -601,7 +615,11 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"total_pupils": latest.get("gias_total_pupils"),
|
"total_pupils": latest.get("gias_total_pupils"),
|
||||||
"trust_name": latest.get("trust_name"),
|
"trust_name": latest.get("trust_name"),
|
||||||
"gender": latest.get("gender"),
|
"gender": latest.get("gender"),
|
||||||
},
|
}.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"school_info": school_info,
|
||||||
"yearly_data": clean_for_json(school_data),
|
"yearly_data": clean_for_json(school_data),
|
||||||
# Supplementary data (null if not yet populated by Kestra)
|
# Supplementary data (null if not yet populated by Kestra)
|
||||||
"ofsted": supplementary.get("ofsted"),
|
"ofsted": supplementary.get("ofsted"),
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ Data loading module — reads from marts.* tables built by dbt.
|
|||||||
Provides efficient queries with caching.
|
Provides efficient queries with caching.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Optional, Dict, Tuple, List
|
from typing import Optional, Dict, Tuple, List
|
||||||
import requests
|
import requests
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
import sqlalchemy.exc
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
@@ -125,6 +128,8 @@ _MAIN_QUERY = text("""
|
|||||||
s.religious_character AS religious_denomination,
|
s.religious_character AS religious_denomination,
|
||||||
s.gender,
|
s.gender,
|
||||||
s.age_range,
|
s.age_range,
|
||||||
|
s.has_sixth_form,
|
||||||
|
s.status,
|
||||||
s.admissions_policy,
|
s.admissions_policy,
|
||||||
s.capacity,
|
s.capacity,
|
||||||
s.total_pupils AS gias_total_pupils,
|
s.total_pupils AS gias_total_pupils,
|
||||||
@@ -214,11 +219,36 @@ _MAIN_QUERY = text("""
|
|||||||
ORDER BY s.school_name, p.year
|
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:
|
def load_school_data_as_dataframe() -> pd.DataFrame:
|
||||||
"""Load all school + KS2 data as a pandas DataFrame."""
|
"""Load all school + KS2 data as a pandas DataFrame."""
|
||||||
try:
|
try:
|
||||||
df = pd.read_sql(_MAIN_QUERY, engine)
|
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:
|
except Exception as exc:
|
||||||
print(f"Warning: Could not load school data from marts: {exc}")
|
print(f"Warning: Could not load school data from marts: {exc}")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class DimSchool(Base):
|
|||||||
religious_character = Column(String(100))
|
religious_character = Column(String(100))
|
||||||
gender = Column(String(20))
|
gender = Column(String(20))
|
||||||
age_range = Column(String(20))
|
age_range = Column(String(20))
|
||||||
|
has_sixth_form = Column(Boolean)
|
||||||
capacity = Column(Integer)
|
capacity = Column(Integer)
|
||||||
total_pupils = Column(Integer)
|
total_pupils = Column(Integer)
|
||||||
headteacher_name = Column(String(200))
|
headteacher_name = Column(String(200))
|
||||||
|
|||||||
@@ -543,6 +543,8 @@ SCHOOL_COLUMNS = [
|
|||||||
"postcode",
|
"postcode",
|
||||||
"religious_denomination",
|
"religious_denomination",
|
||||||
"age_range",
|
"age_range",
|
||||||
|
"has_sixth_form",
|
||||||
|
"status",
|
||||||
"gender",
|
"gender",
|
||||||
"admissions_policy",
|
"admissions_policy",
|
||||||
"ofsted_grade",
|
"ofsted_grade",
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -11,6 +11,8 @@ def convert_to_native(value: Any) -> Any:
|
|||||||
"""Convert numpy types to native Python types for JSON serialization."""
|
"""Convert numpy types to native Python types for JSON serialization."""
|
||||||
if pd.isna(value):
|
if pd.isna(value):
|
||||||
return None
|
return None
|
||||||
|
if isinstance(value, np.bool_):
|
||||||
|
return bool(value)
|
||||||
if isinstance(value, (np.integer,)):
|
if isinstance(value, (np.integer,)):
|
||||||
return int(value)
|
return int(value)
|
||||||
if isinstance(value, (np.floating,)):
|
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 (4–18) 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 | 10–11 (Year 6) | ✅ Live — `marts.fact_ks2_performance` |
|
||||||
|
| **Secondary** | GCSEs, Attainment 8 / Progress 8, EBacc | KS4 | 15–16 (Year 11) | ✅ Live — `marts.fact_ks4_performance` |
|
||||||
|
| **Sixth form** | A levels, applied general, tech levels | KS5 (16–18) | 17–18 (Year 12–13) | ⏳ 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:
|
||||||
|
|
||||||
|
- 16–19 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 (16–18, unfinished GCSE 4+) | `PROGENG_1618` | score |
|
||||||
|
| `maths_progress_1618` | Maths progress (16–18) | `PROGMAT_1618` | score |
|
||||||
|
| `ks5_cohort_size` | Students at end of 16–18 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 | 16–18 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.
|
||||||
@@ -25,6 +25,16 @@ test('home page loads with hero search', async ({ page }) => {
|
|||||||
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
|
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('home hero offers a "use my location" shortcut beside the search box', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// The geolocation shortcut lives inside the hero search card, right under the
|
||||||
|
// search input — not in a separate strip further down the page.
|
||||||
|
const searchInput = page.getByPlaceholder('School name or postcode').first();
|
||||||
|
await expect(searchInput).toBeVisible();
|
||||||
|
const nearMe = page.getByRole('button', { name: /use my location/i });
|
||||||
|
await expect(nearMe).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('searching by name returns school results', async ({ page }) => {
|
test('searching by name returns school results', async ({ page }) => {
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
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 });
|
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 }) => {
|
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
|
// 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.
|
// 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();
|
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 }) => {
|
test('comparing two schools shows both side by side', async ({ page }) => {
|
||||||
// Collect two school URNs from search results, then load the share URL
|
// Collect two school URNs from search results, then load the share URL
|
||||||
await searchByName(page, 'primary');
|
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') || '')
|
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
|
||||||
);
|
);
|
||||||
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
|
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 });
|
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 chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
||||||
const chips = chipGroup.getByRole('button');
|
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.
|
// Tapping a chip focuses that school's line; tapping again releases it.
|
||||||
await chips.first().click();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -212,3 +212,14 @@ describe('computeYBounds', () => {
|
|||||||
expect(computeYBounds([], 'progress')).toEqual({});
|
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) {
|
@media (max-width: 640px) {
|
||||||
.chips {
|
.chips {
|
||||||
display: flex;
|
/* Two chips per row so long school names don't crowd into a single
|
||||||
flex-wrap: wrap;
|
line; each chip fills its column and truncates with an ellipsis. */
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -31,8 +33,8 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-height: 44px;
|
min-height: 40px;
|
||||||
max-width: 100%;
|
min-width: 0;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border: 1px solid rgba(0, 0, 0, .12);
|
border: 1px solid rgba(0, 0, 0, .12);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -58,6 +60,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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;
|
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 {
|
.searchSection {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ interface FilterBarProps {
|
|||||||
filters: Filters;
|
filters: Filters;
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
resultFilters?: ResultFilters;
|
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 router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -182,6 +194,52 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
|
||||||
{!isHero && (
|
{!isHero && (
|
||||||
@@ -310,8 +368,8 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">With or without sixth form</option>
|
<option value="">With or without sixth form</option>
|
||||||
<option value="yes">With sixth form (11-18)</option>
|
<option value="yes">With sixth form</option>
|
||||||
<option value="no">Without sixth form (11-16)</option>
|
<option value="no">Without sixth form</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{admissionsPolicyOptions.length > 0 && (
|
{admissionsPolicyOptions.length > 0 && (
|
||||||
|
|||||||
@@ -369,6 +369,16 @@
|
|||||||
|
|
||||||
.viewToggle {
|
.viewToggle {
|
||||||
justify-content: center;
|
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 {
|
.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 {
|
.quickSearches {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -284,37 +284,11 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
isHero={!isSearchActive}
|
isHero={!isSearchActive}
|
||||||
resultFilters={initialSchools.result_filters}
|
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 */}
|
{/* Admissions countdown strip — only on landing page */}
|
||||||
{!isSearchActive && (
|
{!isSearchActive && (
|
||||||
<section className={styles.admissionsStrip}>
|
<section className={styles.admissionsStrip}>
|
||||||
|
|||||||
@@ -1543,3 +1543,18 @@
|
|||||||
.historyDisclosure[open] > .historyToggle::before {
|
.historyDisclosure[open] > .historyToggle::before {
|
||||||
transform: rotate(90deg);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
formatPercentage, formatProgress, formatAcademicYear,
|
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
|
||||||
@@ -313,6 +313,12 @@ export function SchoolDetailView({
|
|||||||
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isProposedToClose(schoolInfo) && (
|
||||||
|
<div className={styles.closingStrip} role="note">
|
||||||
|
<strong>⚠ Proposed to close</strong> — this school is proposed for closure,
|
||||||
|
check with the local authority before applying.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{schoolInfo.address && (
|
{schoolInfo.address && (
|
||||||
<p className={styles.address}>
|
<p className={styles.address}>
|
||||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
height: 100dvh;
|
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 {
|
.fullscreenBtn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.625rem;
|
top: 0.625rem;
|
||||||
|
|||||||
@@ -33,22 +33,52 @@ interface SchoolMapProps {
|
|||||||
|
|
||||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
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)
|
// Sync state with browser fullscreen events (e.g. Escape key)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => setNativeFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
return () => document.removeEventListener('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(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (!document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
wrapperRef.current?.requestFullscreen();
|
document.exitFullscreen().catch(() => {});
|
||||||
} else {
|
return;
|
||||||
document.exitFullscreen();
|
|
||||||
}
|
}
|
||||||
}, []);
|
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
|
// Calculate center if not provided
|
||||||
const mapCenter: [number, number] = center || (() => {
|
const mapCenter: [number, number] = center || (() => {
|
||||||
@@ -64,7 +94,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''}`}>
|
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''} ${fallbackFullscreen ? styles.fsFallback : ''}`}>
|
||||||
<button
|
<button
|
||||||
className={styles.fullscreenBtn}
|
className={styles.fullscreenBtn}
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
|
|||||||
@@ -254,3 +254,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GIAS "Open, but proposed to close" marker */
|
||||||
|
.attrClosing {
|
||||||
|
background: #fdf6e3;
|
||||||
|
color: #8a6200;
|
||||||
|
border: 1px solid #e2c96f;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
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';
|
import styles from './SchoolRow.module.css';
|
||||||
|
|
||||||
interface SchoolRowProps {
|
interface SchoolRowProps {
|
||||||
@@ -78,6 +78,9 @@ export function SchoolRow({
|
|||||||
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
|
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
|
||||||
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
|
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
|
||||||
{showGender && <span className={styles.attr}>{school.gender}</span>}
|
{showGender && <span className={styles.attr}>{school.gender}</span>}
|
||||||
|
{isProposedToClose(school) && (
|
||||||
|
<span className={`${styles.attr} ${styles.attrClosing}`}>⚠ Proposed to close</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line 3: Key stats */}
|
{/* Line 3: Key stats */}
|
||||||
|
|||||||
@@ -1099,3 +1099,18 @@
|
|||||||
padding: 0.75rem;
|
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,
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} 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 { DeltaChip } from './DeltaChip';
|
||||||
import { track, getNavigationSource } from '@/lib/analytics';
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SecondarySchoolDetailView.module.css';
|
import styles from './SecondarySchoolDetailView.module.css';
|
||||||
@@ -98,7 +98,8 @@ export function SecondarySchoolDetailView({
|
|||||||
|
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
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 hasFinance = finance != null && finance.per_pupil_spend != null;
|
||||||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||||
@@ -236,6 +237,12 @@ export function SecondarySchoolDetailView({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isProposedToClose(schoolInfo) && (
|
||||||
|
<div className={styles.closingStrip} role="note">
|
||||||
|
<strong>⚠ Proposed to close</strong> — this school is proposed for closure,
|
||||||
|
check with the local authority before applying.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{schoolInfo.address && (
|
{schoolInfo.address && (
|
||||||
<p className={styles.address}>
|
<p className={styles.address}>
|
||||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||||
|
|||||||
@@ -266,3 +266,9 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.closingTag {
|
||||||
|
background: #fdf6e3;
|
||||||
|
color: #8a6200;
|
||||||
|
border: 1px solid #e2c96f;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
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';
|
import styles from './SecondarySchoolRow.module.css';
|
||||||
|
|
||||||
function detectAdmissionsTag(school: School): string | null {
|
function detectAdmissionsTag(school: School): string | null {
|
||||||
@@ -23,7 +23,8 @@ function detectAdmissionsTag(school: School): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasSixthForm(school: School): boolean {
|
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 {
|
interface SecondarySchoolRowProps {
|
||||||
@@ -96,6 +97,9 @@ export function SecondarySchoolRow({
|
|||||||
{admissionsTag}
|
{admissionsTag}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isProposedToClose(school) && (
|
||||||
|
<span className={`${styles.provisionTag} ${styles.closingTag}`}>⚠ Proposed to close</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line 3: KS4 stats */}
|
{/* Line 3: KS4 stats */}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface School {
|
|||||||
school_type_code: string | null;
|
school_type_code: string | null;
|
||||||
religious_denomination: string | null;
|
religious_denomination: string | null;
|
||||||
age_range: string | null;
|
age_range: string | null;
|
||||||
|
has_sixth_form?: boolean | null;
|
||||||
|
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
|
||||||
|
|
||||||
// Address
|
// Address
|
||||||
address1: string | null;
|
address1: string | null;
|
||||||
|
|||||||
@@ -718,3 +718,18 @@ export function buildOfstedListBadge(school: {
|
|||||||
|
|
||||||
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
|
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("EstablishmentName", th.StringType),
|
||||||
th.Property("TypeOfEstablishment (name)", th.StringType),
|
th.Property("TypeOfEstablishment (name)", th.StringType),
|
||||||
th.Property("PhaseOfEducation (name)", th.StringType),
|
th.Property("PhaseOfEducation (name)", th.StringType),
|
||||||
|
th.Property("OfficialSixthForm (name)", th.StringType),
|
||||||
th.Property("LA (code)", th.StringType),
|
th.Property("LA (code)", th.StringType),
|
||||||
th.Property("LA (name)", th.StringType),
|
th.Property("LA (name)", th.StringType),
|
||||||
th.Property("EstablishmentNumber", th.StringType),
|
th.Property("EstablishmentNumber", th.StringType),
|
||||||
|
|||||||
@@ -16,10 +16,21 @@ models:
|
|||||||
tests:
|
tests:
|
||||||
- not_null:
|
- not_null:
|
||||||
severity: warn
|
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
|
- name: status
|
||||||
tests:
|
tests:
|
||||||
- accepted_values:
|
- accepted_values:
|
||||||
values: ["Open"]
|
values: ["Open", "Open, but proposed to close"]
|
||||||
|
|
||||||
- name: dim_location
|
- name: dim_location
|
||||||
description: School location dimension with PostGIS geometry
|
description: School location dimension with PostGIS geometry
|
||||||
|
|||||||
@@ -31,4 +31,5 @@ select
|
|||||||
else null
|
else null
|
||||||
end as longitude
|
end as longitude
|
||||||
from {{ ref('stg_gias_establishments') }} s
|
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')
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ select
|
|||||||
s.religious_character,
|
s.religious_character,
|
||||||
s.gender,
|
s.gender,
|
||||||
s.statutory_low_age || '-' || s.statutory_high_age as age_range,
|
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.capacity,
|
||||||
s.total_pupils,
|
s.total_pupils,
|
||||||
concat_ws(' ', s.head_title, s.head_first_name, s.head_last_name) as headteacher_name,
|
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 %}
|
{% if ofsted_relation is not none %}
|
||||||
left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn
|
left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn
|
||||||
{% endif %}
|
{% 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,
|
"EstablishmentName" as school_name,
|
||||||
"TypeOfEstablishment (name)" as school_type,
|
"TypeOfEstablishment (name)" as school_type,
|
||||||
"PhaseOfEducation (name)" as phase,
|
"PhaseOfEducation (name)" as phase,
|
||||||
|
nullif(trim("OfficialSixthForm (name)"), '') as official_sixth_form,
|
||||||
"Gender (name)" as gender,
|
"Gender (name)" as gender,
|
||||||
"ReligiousCharacter (name)" as religious_character,
|
"ReligiousCharacter (name)" as religious_character,
|
||||||
"AdmissionsPolicy (name)" as admissions_policy,
|
"AdmissionsPolicy (name)" as admissions_policy,
|
||||||
|
|||||||
Reference in New Issue
Block a user