Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 5.5 94bfac9caf fix(pipeline): decode GIAS extracts as Windows-1252
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m16s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m27s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m17s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 19s
GIAS publishes its CSVs in Windows-1252 and sends no charset. The tap read
resp.text, so requests guessed the codec, and the encoding="latin-1" passed
to read_csv did nothing on already-decoded text. On 3 Oct 2026 the guess was
windows-1250, and "St Thomas à Becket" (138950, 149557) was stored as
"St Thomas ŕ Becket". A different guess on another day would garble other
accented names.

Both streams now decode the downloaded bytes themselves (gias_csv.py). A byte
Windows-1252 leaves undefined becomes U+FFFD with a logged warning instead of
failing the load, so one odd name cannot stop the daily refresh. None of the
nine extracts checked (1 Jul to 3 Oct 2026) contains such a byte.

Checked by running the tap on the real 3 Oct extract with .text forced to
windows-1250: all 52,586 rows decode, with no "ŕ" and no replacement
characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:09:39 +01:00
42 changed files with 638 additions and 2850 deletions

No files matched your search

+2 -3
View File
@@ -97,9 +97,8 @@ STATIC_SITEMAP_PATHS = ("/", "/rankings", "/compare", "/admissions")
# A page has something a search result could state if any of these is present
# in any year. Shared by _has_publishable_data and the per-school check in
# _school_sitemap_rows so the two can never drift. An inspection with no
# overall grade (ofsted_date alone) still gives a page something to state.
_PUBLISHABLE_FIELDS = ("rwm_expected_pct", "attainment_8_score", "ofsted_grade", "ofsted_date")
# _school_sitemap_rows so the two can never drift.
_PUBLISHABLE_FIELDS = ("rwm_expected_pct", "attainment_8_score", "ofsted_grade")
def _has_publishable_data(row) -> bool:
+56 -37
View File
@@ -18,7 +18,7 @@ from .config import settings
from .database import SessionLocal, engine
from .models import (
DimSchool, DimLocation, KS2Performance,
FactOfstedLatest, FactAdmissions, FactAdmissionDistance,
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
FactDeprivation, FactFinance, FactPupilCharacteristics,
FactKs4Destinations, FactKs5Destinations,
)
@@ -251,11 +251,10 @@ _MAIN_QUERY = text("""
s.website,
s.telephone,
s.nursery_provision,
foi.current_grade AS ofsted_grade,
foi.current_grade_date AS ofsted_grade_date,
foi.latest_visit_date AS ofsted_date,
foi.framework AS ofsted_framework,
foi.rc_inspection_date AS ofsted_rc_date,
foi.ofsted_grade,
foi.ofsted_date,
foi.ofsted_framework,
foi.ofsted_rc_date,
l.local_authority_name AS local_authority,
l.local_authority_code,
l.address_line1 AS address1,
@@ -336,10 +335,21 @@ _MAIN_QUERY = text("""
FROM marts.dim_school s
JOIN marts.dim_location l ON s.urn = l.urn
LEFT JOIN marts.fact_performance p ON s.urn = p.urn
-- One current Ofsted status per school (pipeline: int_ofsted_latest): the
-- grade still in force, dated by the inspection that awarded or confirmed
-- it, and the latest visit of any kind.
LEFT JOIN marts.fact_ofsted_latest foi ON s.urn = foi.urn
LEFT JOIN (
SELECT DISTINCT ON (urn)
urn,
-- Fall back to the ungraded-inspection grade when no graded grade exists.
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
inspection_date AS ofsted_date,
framework AS ofsted_framework,
-- Report-card signal for list/map badges: non-null only when the
-- latest inspection carries report-card grades. framework is the
-- raw event grouping ("Schools - S5"), never "ReportCard", so it
-- can't be used to detect report cards.
rc_inspection_date AS ofsted_rc_date
FROM marts.fact_ofsted_inspection
ORDER BY urn, inspection_date DESC NULLS LAST
) foi ON s.urn = foi.urn
ORDER BY s.school_name, p.year
""")
@@ -721,36 +731,39 @@ def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None)
}
def _iso(d):
return d.isoformat() if d else None
def _ofsted_block(o, urn: int) -> dict:
"""Serialize a fact_ofsted_latest row for API responses.
"""Serialize the latest Ofsted inspection row for API responses.
`current_grade` is the overall grade still in force, dated by the
inspection that awarded or confirmed it; `latest_visit` is the school's
most recent inspection of any kind. The rule lives in int_ofsted_latest
(docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md).
`overall_effectiveness` and `inspection_date` describe the graded
inspection itself and label its area judgements. `report_card` holds the
renewed-framework (Nov 2025) area judgements; safeguarding is a separate
boolean and never appears among the graded areas.
`grade_source` records where the effective overall grade came from:
a graded (Section 5) inspection, or carried forward from an ungraded
(Section 8) outcome — materially different claims a UI must be able
to distinguish. `report_card` holds coded+labelled renewed-framework
(Nov 2025) area judgements; safeguarding is a separate boolean and
never appears among the graded areas.
"""
if o.overall_effectiveness is not None:
grade_source = "graded"
overall = o.overall_effectiveness
elif o.ungraded_grade is not None:
# Fall back to the grade parsed from an ungraded (Section 8) outcome
# (e.g. "School remains Good") so the detail page matches the list badge.
grade_source = "ungraded_carried_forward"
overall = o.ungraded_grade
else:
grade_source = None
overall = None
block = {
"framework": o.framework,
"inspection_date": _iso(o.graded_inspection_date),
"rc_inspection_date": _iso(o.rc_inspection_date),
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
"rc_inspection_date": (
o.rc_inspection_date.isoformat()
if getattr(o, "rc_inspection_date", None)
else None
),
"inspection_type": o.inspection_type,
"overall_effectiveness": o.overall_effectiveness if o.overall_effectiveness in (1, 2, 3, 4) else None,
"current_grade": (
{"grade": o.current_grade, "date": _iso(o.current_grade_date), "basis": o.current_grade_basis}
if o.current_grade is not None else None
),
"latest_visit": (
{"date": _iso(o.latest_visit_date), "kind": o.latest_visit_kind, "outcome": o.latest_visit_outcome}
if o.latest_visit_date else None
),
"overall_effectiveness": overall,
"grade_source": grade_source,
"quality_of_education": o.quality_of_education,
"behaviour_attitudes": o.behaviour_attitudes,
"personal_development": o.personal_development,
@@ -1084,14 +1097,20 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
logging.getLogger(__name__).error("batch supplementary query failed: %s", e)
db.rollback()
# Ofsted — the mart already holds one current status per URN.
# Ofsted — latest inspection per URN. Ordered so the first row seen per
# URN is the most recent.
def _ofsted():
rows = (
db.query(FactOfstedLatest)
.filter(FactOfstedLatest.urn.in_(urns))
db.query(FactOfstedInspection)
.filter(FactOfstedInspection.urn.in_(urns))
.order_by(FactOfstedInspection.urn, FactOfstedInspection.inspection_date.desc())
.all()
)
seen = set()
for o in rows:
if o.urn in seen:
continue
seen.add(o.urn)
result[o.urn]["ofsted"] = _ofsted_block(o, o.urn)
_safe(_ofsted)
-43
View File
@@ -162,49 +162,6 @@ class FactOfstedInspection(Base):
report_url = Column(Text)
class FactOfstedLatest(Base):
"""Current Ofsted status — one row per URN (pipeline: int_ofsted_latest).
`current_grade` is the overall grade still in force, dated by the
inspection that awarded or confirmed it; `latest_visit_*` is the school's
most recent inspection of any kind.
"""
__tablename__ = "fact_ofsted_latest"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
latest_visit_date = Column(Date)
latest_visit_kind = Column(String(20))
latest_visit_outcome = Column(String(100))
current_grade = Column(Integer)
current_grade_date = Column(Date)
current_grade_basis = Column(String(20))
graded_inspection_date = Column(Date)
ungraded_inspection_date = Column(Date)
rc_inspection_date = Column(Date)
inspection_type = Column(String(100))
framework = Column(String(20))
overall_effectiveness = Column(Integer)
quality_of_education = Column(Integer)
behaviour_attitudes = Column(Integer)
personal_development = Column(Integer)
leadership_management = Column(Integer)
early_years_provision = Column(Integer)
sixth_form_provision = Column(Integer)
ungraded_outcome = Column(String(100))
ungraded_grade = Column(Integer)
rc_safeguarding_met = Column(Boolean)
rc_inclusion = Column(Integer)
rc_curriculum_teaching = Column(Integer)
rc_achievement = Column(Integer)
rc_attendance_behaviour = Column(Integer)
rc_personal_development = Column(Integer)
rc_leadership_governance = Column(Integer)
rc_early_years = Column(Integer)
rc_sixth_form = Column(Integer)
report_url = Column(Text)
class FactAdmissions(Base):
"""School admissions — one row per URN per year."""
__tablename__ = "fact_admissions"
-1
View File
@@ -574,7 +574,6 @@ SCHOOL_COLUMNS = [
"gender",
"admissions_policy",
"ofsted_grade",
"ofsted_grade_date",
"ofsted_date",
"ofsted_framework",
"ofsted_rc_date",
+2 -3
View File
@@ -12,8 +12,7 @@ from fastapi.testclient import TestClient
LATEST = 202425
CANNED_SUPPLEMENTARY = {
"ofsted": {"overall_effectiveness": 2,
"current_grade": {"grade": 2, "date": "2023-01-01", "basis": "graded"},
"ofsted": {"overall_effectiveness": 2, "grade_source": "graded",
"report_card": {}, "ofsted_page_url": "https://reports.ofsted.gov.uk/provider/21/100140"},
"census": {"year": 202526, "fsm_pct": 29.8},
"admissions": {"year": 202627, "second_preference_offers": 4},
@@ -87,7 +86,7 @@ def test_each_school_gains_supplementary_blocks(client):
body = client.get("/api/compare?urns=100140,138690").json()
for urn in ("100140", "138690"):
school = body["comparison"][urn]
assert school["ofsted"]["current_grade"]["basis"] == "graded"
assert school["ofsted"]["grade_source"] == "graded"
assert school["census"]["fsm_pct"] == 29.8
assert school["admissions"]["second_preference_offers"] == 4
assert school["admissions_history"][0]["year"] == 202627
@@ -1,56 +0,0 @@
"""The search badge reads ofsted_grade, ofsted_grade_date and ofsted_date from
list rows (nextjs-app/lib/utils.ts buildOfstedListBadge). A field the list
never sends would leave the badge without its year, or worse, fall back to
"Not yet inspected". The school page reads current_grade and latest_visit from
the ofsted block (lib/ofstedStatus.ts)."""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
from backend.schemas import SCHOOL_COLUMNS
def test_list_columns_include_the_status_fields():
for field in ("ofsted_grade", "ofsted_grade_date", "ofsted_date", "ofsted_rc_date"):
assert field in SCHOOL_COLUMNS
def _df() -> pd.DataFrame:
# Rabbsfarm (102408): latest inspection 17 June 2025 gave no overall grade.
return pd.DataFrame([{
"urn": 102408, "school_name": "Rabbsfarm Primary School", "phase": "Primary",
"school_type": "Community school", "local_authority": "Hillingdon",
"address": "Gordon Road, Yiewsley, UB7 8AH", "postcode": "UB7 8AH",
"latitude": 51.51, "longitude": -0.47, "year": 202425, "rwm_expected_pct": 58.0,
"total_pupils": 60, "gias_total_pupils": 616,
"ofsted_grade": np.nan, "ofsted_grade_date": None, "ofsted_date": "2025-06-17",
"ofsted_framework": "Schools - S5", "ofsted_rc_date": None,
}])
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _df)
monkeypatch.setattr(app_module, "load_latest_school_data", _df)
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_search_rows_carry_the_status_fields(client):
resp = client.get("/api/schools")
assert resp.status_code == 200, resp.text
row = resp.json()["schools"][0]
assert row["ofsted_grade"] is None
assert row["ofsted_date"] == "2025-06-17"
assert "ofsted_grade_date" in row
def test_a_school_with_only_an_inspection_is_publishable():
from backend.app import _has_publishable_data
assert _has_publishable_data({"rwm_expected_pct": None, "attainment_8_score": None,
"ofsted_grade": None, "ofsted_date": "2025-06-17"})
+8 -10
View File
@@ -85,15 +85,12 @@ def _ofsted_row(urn, date, oe):
"framework", "inspection_type", "quality_of_education", "behaviour_attitudes",
"personal_development", "leadership_management", "early_years_provision",
"sixth_form_provision", "ungraded_outcome", "ungraded_grade",
"ungraded_inspection_date", "rc_inspection_date", "latest_visit_outcome",
"rc_safeguarding_met", "rc_inclusion", "rc_curriculum_teaching", "rc_achievement",
"rc_attendance_behaviour", "rc_personal_development", "rc_leadership_governance",
"rc_early_years", "rc_sixth_form", "report_url",
)}
when = types.SimpleNamespace(isoformat=lambda: date)
base.update(urn=urn, graded_inspection_date=when, latest_visit_date=when,
latest_visit_kind="graded", overall_effectiveness=oe,
current_grade=oe, current_grade_date=when, current_grade_basis="graded")
base.update(urn=urn, inspection_date=types.SimpleNamespace(isoformat=lambda: date),
overall_effectiveness=oe, grade_source=None)
return types.SimpleNamespace(**base)
@@ -117,9 +114,10 @@ def _dist_row(urn, year, distance_m, route_count=1):
def test_one_query_per_table_and_latest_row_per_urn():
rows = {
# The mart holds one current Ofsted status per URN.
"FactOfstedLatest": [
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
"FactOfstedInspection": [
_ofsted_row(1, "2023-01-01", 2),
_ofsted_row(1, "2019-01-01", 3),
_ofsted_row(2, "2021-06-01", 1),
],
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
@@ -144,14 +142,14 @@ def test_one_query_per_table_and_latest_row_per_urn():
assert sorted(session.queries) == [
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
"FactFinance", "FactKs4Destinations", "FactKs5Destinations",
"FactOfstedLatest", "FactPupilCharacteristics",
"FactOfstedInspection", "FactPupilCharacteristics",
]
# A school with no destination rows gets null, not an empty shell — the
# frontend renders the section from the block's presence.
assert out[1]["destinations"] is None
# Each URN's current Ofsted status
# Latest Ofsted kept per URN
assert out[1]["ofsted"]["overall_effectiveness"] == 2
assert out[2]["ofsted"]["overall_effectiveness"] == 1
@@ -177,7 +175,7 @@ def test_one_query_per_table_and_latest_row_per_urn():
def test_single_wrapper_matches_batch(monkeypatch):
session = _FakeSession({"FactOfstedLatest": [_ofsted_row(5, "2022-01-01", 2)]})
session = _FakeSession({"FactOfstedInspection": [_ofsted_row(5, "2022-01-01", 2)]})
single = data_loader.get_supplementary_data(session, 5)
assert single["ofsted"]["overall_effectiveness"] == 2
assert single["admissions_history"] == []
+10 -35
View File
@@ -10,10 +10,7 @@ from backend.data_loader import _admissions_row_dict, _ofsted_block
def _row(**kw):
base = dict(
framework="RC", inspection_type=None,
graded_inspection_date=None, ungraded_inspection_date=None, rc_inspection_date=None,
latest_visit_date=None, latest_visit_kind=None, latest_visit_outcome=None,
current_grade=None, current_grade_date=None, current_grade_basis=None,
framework="RC", inspection_date=None, inspection_type=None,
overall_effectiveness=None, quality_of_education=None,
behaviour_attitudes=None, personal_development=None,
leadership_management=None, early_years_provision=None,
@@ -36,41 +33,20 @@ def test_report_card_block_and_provider_url():
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140"
def test_no_grade_is_carried_past_a_newer_inspection():
# Rabbsfarm (102408): the 2025 inspection gave no overall grade.
block = _ofsted_block(_row(
graded_inspection_date=date(2025, 6, 17), ungraded_inspection_date=date(2020, 2, 6),
latest_visit_date=date(2025, 6, 17), latest_visit_kind="graded",
ungraded_grade=2, ungraded_outcome="School remains Good", quality_of_education=3,
), urn=102408)
assert block["current_grade"] is None
assert block["overall_effectiveness"] is None
assert block["latest_visit"] == {"date": "2025-06-17", "kind": "graded", "outcome": None}
assert block["inspection_date"] == "2025-06-17"
assert "grade_source" not in block
def test_confirmed_grade_is_dated_by_the_confirming_visit():
block = _ofsted_block(_row(
graded_inspection_date=date(2020, 1, 7), ungraded_inspection_date=date(2024, 7, 18),
latest_visit_date=date(2024, 7, 18), latest_visit_kind="ungraded",
latest_visit_outcome="School remains Good", overall_effectiveness=2,
current_grade=2, current_grade_date=date(2024, 7, 18), current_grade_basis="confirmed",
), urn=104762)
assert block["current_grade"] == {"grade": 2, "date": "2024-07-18", "basis": "confirmed"}
assert block["overall_effectiveness"] == 2
assert block["inspection_date"] == "2020-01-07"
def test_overall_sentinel_is_not_served_as_a_grade():
assert _ofsted_block(_row(overall_effectiveness=9), urn=1)["overall_effectiveness"] is None
def test_grade_source_graded_vs_carried_forward():
assert _ofsted_block(_row(overall_effectiveness=1), urn=1)["grade_source"] == "graded"
carried = _ofsted_block(_row(ungraded_grade=2), urn=1)
assert carried["grade_source"] == "ungraded_carried_forward"
assert carried["overall_effectiveness"] == 2
assert _ofsted_block(_row(), urn=1)["grade_source"] is None
def test_ofsted_block_carries_rc_inspection_date():
o = _row(
ungraded_grade=2,
rc_achievement=1,
rc_inspection_date=date(2026, 2, 3),
graded_inspection_date=date(2021, 10, 7),
inspection_date=date(2021, 10, 7),
)
block = _ofsted_block(o, urn=138690)
assert block["rc_inspection_date"] == "2026-02-03"
@@ -79,7 +55,7 @@ def test_ofsted_block_carries_rc_inspection_date():
def test_ofsted_block_rc_inspection_date_none_when_absent():
o = _row(overall_effectiveness=1, graded_inspection_date=date(2021, 10, 13))
o = _row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
block = _ofsted_block(o, urn=136276)
assert block["rc_inspection_date"] is None
@@ -87,7 +63,6 @@ def test_ofsted_block_rc_inspection_date_none_when_absent():
def test_ofsted_block_keeps_existing_keys():
block = _ofsted_block(_row(overall_effectiveness=2, quality_of_education=2), urn=1)
for key in ("framework", "inspection_date", "overall_effectiveness",
"current_grade", "latest_visit",
"quality_of_education", "rc_inclusion", "report_url"):
assert key in block
File diff suppressed because it is too large. Load diff
@@ -1,257 +0,0 @@
# Ofsted Current Status — Design
**Date:** 2026-10-05
**Status:** approved design, not yet implemented
**Scope:** `pipeline/transform` Ofsted models, `backend/data_loader.py`, list and
detail API Ofsted fields, search and map badges, school-page Ofsted section,
compare Ofsted rows, Typesense rating, sitemap
**Fixes:** audit findings C1, M1 and (as a side effect) M2 and part of H3,
from the 3 Oct 2026 accuracy audit
## Goal
Never show an Ofsted grade under a date it was not awarded or confirmed on, and
always date "Inspected" by the school's latest visit.
## The problem
Ofsted's management information gives each school at most three inspections:
the latest graded inspection (date G, an overall grade or "Not judged", area
grades), the latest ungraded inspection (date U, an outcome sentence) and the
latest report card (date RC).
The site derives a grade and a date from these with two independent rules:
- grade = the graded inspection's overall grade, or else the grade parsed from
the ungraded outcome ("School remains Good" → 2);
- date = G, or else U.
The two rules can pick different inspections. Every inspection from
September 2024 to November 2025 was graded with "Not judged" overall, so the
grade falls back to an older ungraded visit while the date stays the new one.
- **C1.** Rabbsfarm Primary School (102408) shows "Good · 2025". Ofsted's
17 June 2025 inspection gave no overall grade and rated quality of education,
behaviour and leadership Requires Improvement. The "Good" comes from an
ungraded visit on 6 February 2020. Site-wide, 932 badges pair a
carried-forward grade with a newer inspection's year, and 147 of them say Good
or Outstanding while that inspection rated an area Requires Improvement or
Inadequate. Acre Wood Academy (151783) reads "Good · 2024" though the
October 2024 inspection rated all four areas Inadequate.
- **M1.** When a newer ungraded visit exists, the page shows the older graded
date. Washwood Heath Academy (139888) reads "Inspected 3 Mar 2020 · 4+ years
ago"; Ofsted visited on 21 May 2025. 667 schools.
Ofsted's own provider page for Rabbsfarm leads with the 2025 area judgements and
"From September 2024, Ofsted no longer makes an overall effectiveness
judgement". It shows no overall grade.
The rule is also implemented four times: `dim_school.ofsted_grade` (feeds
Typesense), the list SQL in `data_loader.py`, `_ofsted_block`, and two separate
"latest row" picks over `marts.fact_ofsted_inspection` by `inspection_date`,
which tie arbitrarily on duplicate monthly rows.
## Non-goals
- Predecessor inspections (audit M10): a grade Ofsted attributes to a previous
URN stays unlabelled.
- Post-16 and ISI-inspected schools (H4) and the "Not yet inspected" label.
- The compare page's broken Ofsted link (M4).
- Report-card display, which is unchanged.
## The rule
Computed once per URN in `int_ofsted_latest`.
**Latest visit:** the newest of RC, G and U.
- `latest_visit_date`
- `latest_visit_kind`: `report_card`, `graded` or `ungraded`
- `latest_visit_outcome`: the ungraded outcome text when the kind is `ungraded`,
otherwise null
**Current grade:** the overall grade still in force, if any.
| Situation | `current_grade` | `current_grade_date` | `current_grade_basis` |
|---|---|---|---|
| A report card exists | null | null | null |
| Latest is graded, overall 1–4 | that grade | G | `graded` |
| Latest is graded, "Not judged" | null | null | null |
| Latest is ungraded, outcome "School remains X…" (any qualifier) | X | U | `confirmed` |
| Latest is ungraded, any other outcome ("Standards maintained", "Improved significantly", "Some aspects not as strong") | the graded inspection's overall grade if it is 1–4, else null | G when a grade is kept | `graded` when a grade is kept |
| No inspection | null | null | null |
A report card replaced overall grades, so no legacy grade stays in force beside
one. The latest visit is read from the dates, not assumed: report cards began in
November 2025, after the last legacy graded and ungraded inspections, and in
Ofsted's 31 Aug 2026 data no school has a legacy visit newer than its report
card, but the rule does not depend on that. Ties between G, U and RC on the same
date resolve in the order report card, graded, ungraded.
Invariants: `current_grade` is null or 1–4; `current_grade_date <=
latest_visit_date`; `current_grade_basis` is null exactly when `current_grade`
is null.
### Expected results (Ofsted MI as at 31 Aug 2026)
| URN | School | Ofsted data | Latest visit | Current grade |
|---|---|---|---|---|
| 102408 | Rabbsfarm Primary School | G 17 Jun 2025 Not judged; U 6 Feb 2020 remains Good | graded, 17 Jun 2025 | none |
| 151783 | Acre Wood Academy | G 1 Oct 2024 Not judged; U 14 Mar 2023 remains Good (Concerns) | graded, 1 Oct 2024 | none |
| 139888 | Washwood Heath Academy | G 3 Mar 2020 Good; U 21 May 2025 Standards maintained | ungraded, 21 May 2025, "Standards maintained" | Good, 3 Mar 2020, graded |
| 104762 | Robins Lane Community Primary | G 7 Jan 2020 Good; U 18 Jul 2024 School remains Good | ungraded, 18 Jul 2024 | Good, 18 Jul 2024, confirmed |
| 100094 | Royal Free Hospital Children's School | G 9 Oct 2019 Outstanding; U 5 Feb 2025 Some aspects not as strong | ungraded, 5 Feb 2025 | Outstanding, 9 Oct 2019, graded |
| 136454 | Oakgrove School | U 13 Nov 2024 Standards maintained only | ungraded, 13 Nov 2024 | none |
| 137086 | Bishop Stopford School | G 1 Apr 2025 Not judged | graded, 1 Apr 2025 | none |
| 110048 | The Willink School | U 5 Oct 2023 remains Good; RC 6 May 2026 | report card, 6 May 2026 | none (report card shown) |
| 149612 | St Michael's Catholic School | RC 10 Feb 2026 only | report card, 10 Feb 2026 | none (report card shown) |
## What each page shows
**Search and map badge** (`buildOfstedListBadge`), first match wins:
1. Report card: "Report Card · *RC year*" (unchanged)
2. Current grade: "*Grade* · *year of `current_grade_date`*"
3. Latest visit: "Inspected · *year of `latest_visit_date`*"
4. "Not yet inspected" (unchanged)
**School page** (`OfstedSection`, both phases):
- Title date: "Inspected *latest visit date*".
- Headline: the report card; or the current grade with a source line
("Graded inspection, 6 July 2016" or "Confirmed at an ungraded inspection,
14 March 2023"); or "No overall grade" with "Ofsted stopped giving overall
grades in September 2024".
- "Latest visit" line when the latest visit is not the grade's source, e.g.
"Ungraded inspection, 13 Nov 2024: Standards maintained".
- The area grid shows the graded inspection's judgements through
`ofstedLegacyAreas()`, dated by that inspection when it is not the latest
visit. The primary and secondary no-grade branches merge into one; the
secondary branch's four hard-coded areas (audit M2) go with it.
**Compare:** `ofstedDisplay` returns `report_card`, `graded`, `confirmed`,
`no_overall_grade` or `none`. The "Latest Ofsted inspection", "Result" and
"Inspected" rows use the same fields as the school page.
## Delivery
Two pull requests. The mart columns exist before anything reads them, so
neither needs compatibility code.
### PR 1: pipeline (additive)
- `stg_ofsted_inspections`: keep `graded_inspection_date`,
`ungraded_inspection_date` and `rc_inspection_date` as separate typed
columns, with the report-card date's existing guard. Keep `inspection_date`
(graded, else ungraded) for the current backend. Keep a row when any of the
three dates is present, so report-card-only schools are no longer dropped
(part of H3: 123 schools).
- `int_ofsted_latest`: pick one row per URN by `latest_visit_date` descending,
then `rc_inspection_date`, `ungraded_inspection_date` and
`graded_inspection_date` descending (nulls last). A duplicate monthly row that
carries a newer report card therefore always wins. Add the five status
columns.
- New mart `marts.fact_ofsted_latest`: one row per URN from `int_ofsted_latest`
with every column the pages need (status, area grades, report-card grades,
ungraded outcome, report URL). It does not join `dim_school`, so only the
monthly Ofsted DAG builds it.
- `dim_school` is not changed in PR 1: the daily DAG does not rebuild
`int_ofsted_latest`, and reading a column that the monthly DAG has not yet
built would fail the daily run.
- Visible effect: report-card-only schools gain their report card, because the
backend's existing reads of `fact_ofsted_inspection` now see their rows.
Nothing else changes.
### PR 2: backend and UI (after the Ofsted DAG has run on PR 1)
- `data_loader.py`: the list query and the batch query read
`marts.fact_ofsted_latest` instead of picking the latest row of
`fact_ofsted_inspection`. `_ofsted_block` reads the status columns and loses
its fallback to `ungraded_grade`.
- List rows: `ofsted_grade` becomes `current_grade`; `ofsted_date` becomes
`latest_visit_date`; new `ofsted_grade_date`. `ofsted_rc_date` stays.
- `ofsted` block: `overall_effectiveness` and `inspection_date` are the graded
inspection's own result and date (they label the area grid); new
`current_grade` `{grade, date, basis}` (or null) and `latest_visit`
`{date, kind, outcome}`; `grade_source` is removed. Report-card fields are
unchanged.
- `dim_school.ofsted_grade` becomes `current_grade` (Typesense's rating follows
at the next sync); `ofsted_date` becomes `latest_visit_date`.
- Sitemap: `lastmod` from `latest_visit_date`; `_PUBLISHABLE_FIELDS` also counts
a latest visit, so schools that lose a carried grade keep their sitemap entry.
- Front end: `lib/types.ts`, `buildOfstedListBadge`, `OfstedSection`,
`PrimarySchoolSections`, `SecondarySchoolSections`, `compareLogic.ofstedDisplay`,
`CompareAtAGlance`, `CompareOfsted`. Place-page counts need no change.
- Delete `buildOfstedHeroChip` and `buildSchoolSummary` in their own commit:
nothing renders them and they encode the old rule.
## Testing
**PR 1**
- dbt unit tests on `int_ofsted_latest`, one per table row above plus
"report-card only" and "duplicate rows, newer report card wins".
- Schema tests on `fact_ofsted_latest`: unique, not-null `urn`; accepted values
for `latest_visit_kind` and `current_grade_basis`; `current_grade` null or
1–4; `current_grade_date <= latest_visit_date`.
- Run locally against a throwaway Postgres from `pgserver` (no Docker here). If
that fails, they still run in the Ofsted DAG's `dbt build`, which fails on any
broken case.
- `pipeline/tests/test_dag_selectors.py` (PR #181) keeps passing.
**PR 2**
- pytest: contract test for the list and `ofsted` fields (style of
`test_school_page_flag_fields.py`); `_ofsted_block` from a
`fact_ofsted_latest` row; sitemap publishability.
- Jest: a badge case per table row; `OfstedSection` for graded, confirmed, no
grade, the latest-visit line and a sixth-form area; `ofstedDisplay` kinds.
Rewrite tests that assert `carried_forward`.
- E2E (same PR): Rabbsfarm's search row says "Inspected · 2025" and its page
says "No overall grade" with quality of education Requires Improvement; a
confirmed school says "Confirmed at an ungraded inspection". The existing
report-card journey stays.
## Rollout and verification
1. Merge PR 1. On staging, run `school_data_monthly_ofsted`, then:
```sql
-- one row per school
select count(*) = count(distinct urn) from marts.fact_ofsted_latest;
-- the examples above
select urn, latest_visit_date, latest_visit_kind, latest_visit_outcome,
current_grade, current_grade_date, current_grade_basis
from marts.fact_ofsted_latest
where urn in (102408, 151783, 139888, 104762, 100094, 136454, 137086, 110048, 149612);
-- C1: a grade in force although the latest inspection gave none (expect 0)
select count(*) from marts.fact_ofsted_latest
where current_grade is not null and latest_visit_kind = 'graded'
and overall_effectiveness is null;
-- invariant (expect 0)
select count(*) from marts.fact_ofsted_latest where current_grade_date > latest_visit_date;
```
Check through the API that St Michael's Catholic School (149612) shows its
report card.
2. Promote PR 1 to production; run the Ofsted DAG there; repeat the checks.
3. Merge PR 2. Let the daily DAG run (or trigger it) so `dim_school` and
Typesense pick up the change; run the E2E journeys; re-run the audit's C1,
M1 and M2 checks against staging: expect 0.
4. Promote PR 2; repeat the audit checks on production.
## Expected visible change
About 932 schools change from a grade badge dated by a no-grade inspection
("Good · 2025") to "Inspected · 2025". Counts of Good and Outstanding schools
on place pages fall by the same schools, and Typesense's rating changes for
them. Dates beside a grade can move earlier (to the inspection that awarded
it); "Inspected" dates move later (to the latest visit).
## Risks
- Ofsted changes its MI columns most months. Unknown grade text parses to null
(`safe_numeric`), which degrades to "Inspected · year", never to a wrong grade.
- PR 2 depends on the Ofsted DAG having run on the target environment after PR
1. If PR 2 is promoted first, the backend reads a missing table: promote in
order.
-31
View File
@@ -474,37 +474,6 @@ test('a report-card school shows a Report Card badge in search results, not its
await expect(page.getByText(/Report Card ·/).first()).toBeVisible();
});
test('a school whose latest inspection gave no grade is not shown with an older grade', async ({ page }) => {
// Rabbsfarm (102408): Ofsted's 17 June 2025 inspection gave no overall
// grade and rated three areas Requires Improvement. The site used to carry
// a 2020 "remains Good" forward and print "Good · 2025" (audit C1).
const URN = 102408;
const res = await page.request.get(`/api/schools/${URN}`);
expect(res.ok()).toBeTruthy();
const ofsted = (await res.json()).ofsted;
expect(ofsted.current_grade).toBeNull();
expect(ofsted.latest_visit.date).toBe('2025-06-17');
await searchByName(page, 'Rabbsfarm Primary');
const row = page.locator(`a[href*="${URN}"]`).first();
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Inspected · 2025').first()).toBeVisible();
await expect(page.getByText(/Good · 2025/)).toHaveCount(0);
await page.goto(`/school/${URN}`);
const section = page.locator('#ofsted');
await expect(section.getByText('No overall grade')).toBeVisible();
await expect(section.getByText('Requires Improvement').first()).toBeVisible();
});
test('a grade confirmed at an ungraded visit says so and is dated by it', async ({ page }) => {
const URN = 104762; // Robins Lane: graded Good Jan 2020, "School remains Good" 18 July 2024
await page.goto(`/school/${URN}`);
const section = page.locator('#ofsted');
await expect(section).toBeVisible({ timeout: 15_000 });
await expect(section.getByText('Confirmed at an ungraded inspection, 18 July 2024')).toBeVisible();
});
test('school detail page renders name and performance data', async ({ page }) => {
await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first();
@@ -33,26 +33,18 @@ function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
};
}
const schools = [school(1, 'Graded School'), school(2, 'Confirmed School'), school(3, 'Card School')];
const schools = [school(1, 'Graded School'), school(2, 'Carried School'), school(3, 'Card School')];
const data: Record<string, ComparisonData> = {
'1': {
school_info: schools[0],
yearly_data: [],
ofsted: ofsted({
overall_effectiveness: 1,
current_grade: { grade: 1, date: '2021-10-07', basis: 'graded' },
latest_visit: { date: '2021-10-07', kind: 'graded', outcome: null },
}),
ofsted: ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
},
'2': {
school_info: schools[1],
yearly_data: [],
ofsted: ofsted({
overall_effectiveness: 2,
current_grade: { grade: 2, date: '2023-03-14', basis: 'confirmed' },
latest_visit: { date: '2023-03-14', kind: 'ungraded', outcome: 'School remains Good' },
}),
ofsted: ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
},
'3': {
school_info: schools[2],
@@ -73,9 +65,9 @@ describe('CompareOfsted', () => {
render(<CompareOfsted schools={schools} data={data} />);
expect(screen.getByText('Outstanding')).toBeInTheDocument();
// A grade confirmed at an ungraded visit says so, with that visit's date
// Carried-forward grade is shown but marked as such
expect(screen.getByText('Good')).toBeInTheDocument();
expect(screen.getByText('Confirmed at an ungraded inspection, 14 March 2023')).toBeInTheDocument();
expect(screen.getByText(/carried forward/i)).toBeInTheDocument();
// Report card: label present, no overall-grade badge for that school
expect(screen.getByText('Report card')).toBeInTheDocument();
expect(screen.getByText(/no overall grade/i)).toBeInTheDocument();
@@ -112,7 +104,7 @@ describe('CompareOfsted', () => {
yearly_data: [],
ofsted: ofsted({
overall_effectiveness: 2,
current_grade: { grade: 2, date: '2021-10-07', basis: 'graded' },
grade_source: 'graded',
quality_of_education: 1,
early_years_provision: 9,
sixth_form_provision: 2,
@@ -175,26 +167,4 @@ describe('CompareOfsted', () => {
expect(screen.getAllByText('Graded').length).toBe(4);
expect(screen.getAllByText('Card').length).toBe(4);
});
it('dates "Inspected" by the latest visit, not the graded inspection (Washwood Heath)', () => {
const s1 = school(7, 'Washwood Heath Academy');
render(<CompareOfsted schools={[s1]} data={{ '7': { school_info: s1, yearly_data: [], ofsted: ofsted({
inspection_date: '2020-03-03', overall_effectiveness: 2, quality_of_education: 2,
current_grade: { grade: 2, date: '2020-03-03', basis: 'graded' },
latest_visit: { date: '2025-05-21', kind: 'ungraded', outcome: 'Standards maintained' },
}) } }} />);
expect(screen.getByText(/21 May 2025/)).toBeInTheDocument();
expect(screen.queryByText('4+ years ago')).not.toBeInTheDocument();
expect(screen.getByText('Graded inspection, 3 March 2020')).toBeInTheDocument();
});
it('shows no overall grade after a no-grade inspection (Rabbsfarm)', () => {
const s1 = school(8, 'Rabbsfarm Primary School');
render(<CompareOfsted schools={[s1]} data={{ '8': { school_info: s1, yearly_data: [], ofsted: ofsted({
inspection_date: '2025-06-17', quality_of_education: 3,
current_grade: null, latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null },
}) } }} />);
expect(screen.getAllByText('No overall grade').length).toBeGreaterThan(0);
expect(screen.queryByText('Good')).not.toBeInTheDocument();
});
});
@@ -8,6 +8,7 @@
import { render, screen } from '@testing-library/react';
import {
nearbyNoun,
NearbySchoolsSection,
shouldRenderNearby,
} from '@/components/school/NearbySchoolsSection';
@@ -42,7 +43,13 @@ function school(overrides: Partial<NearbySchool> = {}): NearbySchool {
function renderSection(nearby: NearbySchool[]) {
return render(
<NearbySchoolsSection urn={100001} thisMetricValue={72} nearby={nearby} />,
<NearbySchoolsSection
urn={100001}
schoolName="Meadowbrook Primary School"
phase="Primary"
thisMetricValue={72}
nearby={nearby}
/>,
);
}
@@ -83,10 +90,15 @@ describe('what the section claims', () => {
});
it('shows no chips at all when nothing is shared, rather than inventing one', () => {
const { container } = renderSection([
school({ shared: [] }),
school({ urn: 100003, shared: [] }),
]);
const { container } = render(
<NearbySchoolsSection
urn={100001}
schoolName="Meadowbrook Primary School"
phase="Primary"
thisMetricValue={72}
nearby={[school({ shared: [] }), school({ urn: 100003, shared: [] })]}
/>,
);
// The card still carries its distance, name, type and figure — just no
// claim of likeness.
expect(container.querySelectorAll('li ul').length).toBe(0);
@@ -94,6 +106,37 @@ describe('what the section claims', () => {
});
});
describe('what the lede calls the set', () => {
it.each([
['Primary', 'primary schools'],
['Middle deemed primary', 'primary schools'],
['Secondary', 'secondary schools'],
['Middle deemed secondary', 'secondary schools'],
['All-through', 'all-through schools'],
// GIAS phase 6. Its candidates span the whole secondary group, so no
// single noun fits and it takes the honest general one.
['16 plus', 'schools and colleges'],
['', 'schools'],
[null, 'schools'],
])('calls a %s school\'s neighbours "%s"', (phase, expected) => {
expect(nearbyNoun(phase)).toBe(expected);
});
it('never calls a sixth form college\'s neighbours primary schools', () => {
render(
<NearbySchoolsSection
urn={100001}
schoolName="Barnet Sixth Form College"
phase="16 plus"
thisMetricValue={null}
nearby={[school(), school({ urn: 100003 })]}
/>,
);
expect(screen.getByText(/Other schools and colleges near Barnet Sixth Form College/)).toBeInTheDocument();
expect(screen.queryByText(/primary schools/)).not.toBeInTheDocument();
});
});
describe('cards', () => {
it('links each school to its canonical slug', () => {
renderSection([school(), school({ urn: 100003, school_name: 'Oakfield Primary School' })]);
@@ -1,84 +0,0 @@
import { render, screen } from '@testing-library/react';
import { OfstedSection } from '@/components/school/OfstedSection';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { OfstedInspection } from '@/lib/types';
const empty = {
framework: null, inspection_date: null, inspection_type: null, overall_effectiveness: null,
quality_of_education: null, behaviour_attitudes: null, personal_development: null,
leadership_management: null, early_years_provision: null, sixth_form_provision: null,
previous_overall: null, rc_safeguarding_met: null, rc_inclusion: null, rc_curriculum_teaching: null,
rc_achievement: null, rc_attendance_behaviour: null, rc_personal_development: null,
rc_leadership_governance: null, rc_early_years: null, rc_sixth_form: null, report_url: null,
report_card: {},
} as OfstedInspection;
// `page` mimics how each school page used to mount the section; the secondary
// page's branch hard-coded four areas and dropped the sixth form (audit M2).
function renderSection(o: Partial<OfstedInspection>, page: Record<string, unknown> = {}) {
const ofsted = { ...empty, ...o } as OfstedInspection;
render(
<OfstedSection
{...page}
ofsted={ofsted}
urn={1}
isReportCard={false}
ofstedInspectedDate={ofsted.latest_visit?.date ?? null}
oeifAllSameGrade={false}
oeifAreas={ofstedLegacyAreas(ofsted)}
/>,
);
}
it('shows no overall grade, not an older one, after a no-grade inspection (Rabbsfarm)', () => {
renderSection({
inspection_date: '2025-06-17', quality_of_education: 3, behaviour_attitudes: 3,
personal_development: 2, leadership_management: 3, early_years_provision: 2,
current_grade: null, latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null },
});
expect(screen.getByText(/Inspected 17 June 2025/)).toBeInTheDocument();
expect(screen.getByText('No overall grade')).toBeInTheDocument();
expect(screen.queryByText('Good', { selector: 'span' })).not.toBeInTheDocument();
expect(screen.queryByText('Not rated')).not.toBeInTheDocument();
expect(screen.getAllByText('Requires Improvement')).toHaveLength(3);
});
it('keeps the sixth-form judgement when there is no overall grade (audit M2)', () => {
renderSection({
inspection_date: '2025-04-01', quality_of_education: 1, behaviour_attitudes: 1,
personal_development: 1, leadership_management: 1, sixth_form_provision: 2,
current_grade: null, latest_visit: { date: '2025-04-01', kind: 'graded', outcome: null },
}, { variant: 'secondary' });
expect(screen.getByText(/sixth form/i)).toBeInTheDocument();
expect(screen.getByText('Good')).toBeInTheDocument();
});
it('dates a grade by its source and prints a later visit separately (Washwood Heath)', () => {
renderSection({
inspection_date: '2020-03-03', overall_effectiveness: 2, quality_of_education: 2,
current_grade: { grade: 2, date: '2020-03-03', basis: 'graded' },
latest_visit: { date: '2025-05-21', kind: 'ungraded', outcome: 'Standards maintained' },
});
expect(screen.getByText(/Inspected 21 May 2025/)).toBeInTheDocument();
expect(screen.getByText('Graded inspection, 3 March 2020')).toBeInTheDocument();
expect(screen.getByText(/Latest visit: Ungraded inspection, 21 May 2025: Standards maintained/)).toBeInTheDocument();
});
it('says a grade was confirmed at an ungraded visit (Robins Lane)', () => {
renderSection({
inspection_date: '2020-01-07', overall_effectiveness: 2,
current_grade: { grade: 2, date: '2024-07-18', basis: 'confirmed' },
latest_visit: { date: '2024-07-18', kind: 'ungraded', outcome: 'School remains Good' },
});
expect(screen.getByText('Confirmed at an ungraded inspection, 18 July 2024')).toBeInTheDocument();
expect(screen.queryByText(/Latest visit/)).not.toBeInTheDocument();
});
it('prints an ungraded outcome when there is no grade (Oakgrove)', () => {
renderSection({
current_grade: null,
latest_visit: { date: '2024-11-13', kind: 'ungraded', outcome: 'Standards maintained' },
});
expect(screen.getByText('No overall grade')).toBeInTheDocument();
expect(screen.getByText(/Ungraded inspection, 13 November 2024: Standards maintained/)).toBeInTheDocument();
});
+15 -12
View File
@@ -114,13 +114,16 @@ describe('ofstedDisplay', () => {
expect(d.kind).toBe('report_card');
});
it('distinguishes a graded grade from a confirmed one', () => {
expect(ofstedDisplay(ofsted({ current_grade: { grade: 1, date: '2019-10-09', basis: 'graded' },
latest_visit: { date: '2025-02-05', kind: 'ungraded', outcome: 'Some aspects not as strong' } })))
.toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', gradeDate: '2019-10-09' });
expect(ofstedDisplay(ofsted({ current_grade: { grade: 2, date: '2024-07-18', basis: 'confirmed' },
latest_visit: { date: '2024-07-18', kind: 'ungraded', outcome: 'School remains Good' } })))
.toMatchObject({ kind: 'confirmed', gradeLabel: 'Good' });
it('distinguishes graded from carried-forward grades', () => {
const graded = ofstedDisplay(
ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
);
expect(graded).toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', carriedForward: false });
const carried = ofstedDisplay(
ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
);
expect(carried).toMatchObject({ kind: 'carried_forward', gradeLabel: 'Good', carriedForward: true });
});
it('handles missing data', () => {
@@ -128,11 +131,11 @@ describe('ofstedDisplay', () => {
expect(ofstedDisplay(ofsted({})).kind).toBe('none');
});
it('has no overall grade when the latest inspection gave none', () => {
// Rabbsfarm: the graded inspection's own overall is null, so no grade is
// in force even though an older ungraded visit said "remains Good".
expect(ofstedDisplay(ofsted({ current_grade: null, overall_effectiveness: null,
latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).kind).toBe('no_overall_grade');
it('identifies transitional inspections without overall grades', () => {
const transitional = ofstedDisplay(
ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }),
);
expect(transitional.kind).toBe('transitional');
});
it('uses the four legacy grade words', () => {
@@ -1,47 +0,0 @@
import { gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
import type { OfstedInspection } from '@/lib/types';
const base = { report_card: {} } as unknown as OfstedInspection;
describe('gradeSourceLine', () => {
it('names the graded inspection and its date', () => {
expect(gradeSourceLine({ grade: 2, date: '2016-07-06', basis: 'graded' }))
.toBe('Graded inspection, 6 July 2016');
});
it('names the ungraded visit that confirmed it', () => {
expect(gradeSourceLine({ grade: 2, date: '2023-03-14', basis: 'confirmed' }))
.toBe('Confirmed at an ungraded inspection, 14 March 2023');
});
});
describe('latestVisitLine', () => {
it('prints the ungraded outcome', () => {
expect(latestVisitLine({ date: '2024-11-13', kind: 'ungraded', outcome: 'Standards maintained' }))
.toBe('Ungraded inspection, 13 November 2024: Standards maintained');
});
it('prints a graded visit without an outcome', () => {
expect(latestVisitLine({ date: '2025-06-17', kind: 'graded', outcome: null }))
.toBe('Graded inspection, 17 June 2025');
});
});
describe('showLatestVisitLine', () => {
it('shows a later visit than the one the grade came from', () => {
expect(showLatestVisitLine({ ...base,
current_grade: { grade: 2, date: '2020-03-03', basis: 'graded' },
latest_visit: { date: '2025-05-21', kind: 'ungraded', outcome: 'Standards maintained' } })).toBe(true);
});
it('hides it when the visit is the grade’s own source', () => {
expect(showLatestVisitLine({ ...base,
current_grade: { grade: 2, date: '2024-07-18', basis: 'confirmed' },
latest_visit: { date: '2024-07-18', kind: 'ungraded', outcome: 'School remains Good' } })).toBe(false);
});
it('shows an ungraded outcome when there is no grade', () => {
expect(showLatestVisitLine({ ...base, current_grade: null,
latest_visit: { date: '2024-11-13', kind: 'ungraded', outcome: 'Standards maintained' } })).toBe(true);
});
it('hides it for a graded visit with no grade (the title already dates it)', () => {
expect(showLatestVisitLine({ ...base, current_grade: null,
latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).toBe(false);
});
});
+6 -20
View File
@@ -147,37 +147,23 @@ describe('ofstedLegacyAreas', () => {
describe('buildOfstedListBadge', () => {
it('returns grade word + year for OEIF Outstanding', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_grade_date: '2023-11-15', ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
expect(badge.label).toBe('Outstanding · 2023');
expect(badge.cssClass).toBe('ofsted1');
});
it('returns grade word for each OEIF grade', () => {
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2022-05-01' }).label).toBe('Good · 2022');
expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_grade_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021');
expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_grade_date: '2020-03-01' }).label).toBe('Inadequate · 2020');
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: '2022-05-01' }).label).toBe('Good · 2022');
expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021');
expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_date: '2020-03-01' }).label).toBe('Inadequate · 2020');
});
it('prints a grade without a year when its date is missing', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: null, ofsted_date: '2025-01-01' });
it('returns grade word without year when date is missing', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: null });
expect(badge.label).toBe('Good');
expect(badge.cssClass).toBe('ofsted2');
});
it('dates a grade by the inspection that awarded it, not the latest visit', () => {
// Washwood Heath: Good from a March 2020 graded inspection; latest visit
// an ungraded one in May 2025 (audit M1).
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2020-03-03', ofsted_date: '2025-05-21' });
expect(badge.label).toBe('Good · 2020');
});
it('shows Inspected for a latest inspection that gave no grade (Rabbsfarm)', () => {
// Audit C1: this used to read "Good · 2025", from a 2020 ungraded visit.
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_grade_date: null, ofsted_date: '2025-06-17' });
expect(badge.label).toBe('Inspected · 2025');
expect(badge.cssClass).toBe('ofstedInspected');
});
it('returns a Report Card badge when ofsted_rc_date is present', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' });
expect(badge.label).toBe('Report Card · 2026');
@@ -191,8 +191,7 @@ export const primaryFixture = {
personal_development: 2,
leadership_management: 2,
previous_overall: 3,
current_grade: { grade: 2, date: '2023-05-17', basis: 'graded' },
latest_visit: { date: '2023-05-17', kind: 'graded', outcome: null },
grade_source: 'graded',
}),
census,
admissions: makeAdmissions({ year: 2024 }),
@@ -296,8 +295,7 @@ export const allThroughFixture = {
behaviour_attitudes: 1,
personal_development: 1,
leadership_management: 1,
current_grade: { grade: 1, date: '2022-10-04', basis: 'graded' },
latest_visit: { date: '2022-10-04', kind: 'graded', outcome: null },
grade_source: 'graded',
}),
census,
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
@@ -78,18 +78,21 @@ export function CompareAtAGlance({
return (
<Cell key={school.urn} school={school} index={i}>
{display.kind === 'report_card' && <ReportCardChips summary={display.summary} />}
{(display.kind === 'graded' || display.kind === 'confirmed') && (
{(display.kind === 'graded' || display.kind === 'carried_forward') && (
<>
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
{display.gradeLabel}
</span>
{display.kind === 'confirmed' && <span className={s.small}>Confirmed at an ungraded inspection</span>}
{display.carriedForward && <span className={s.small}>Grade carried forward</span>}
</>
)}
{display.kind === 'no_overall_grade' && (
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
No overall grade
</span>
{display.kind === 'transitional' && (
<>
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
No overall grade
</span>
<span className={s.small}>Sub-judgements only</span>
</>
)}
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
</Cell>
+22 -14
View File
@@ -1,8 +1,7 @@
/**
* Ofsted section — one visual grammar for inspection detail across all
* three regimes (legacy graded, no overall grade, renewed-framework report
* card). The grade shown is the one still in force, with the inspection that
* awarded or confirmed it; "Inspected" is the latest visit.
* three regimes (legacy graded, interim carried-forward, renewed-framework
* report card). Copy comes verbatim from the reviewed mockups.
*/
'use client';
@@ -13,8 +12,7 @@ import {
rcAreaLabel,
type OfstedDisplay,
} from '@/lib/compareLogic';
import { gradeSourceLine } from '@/lib/ofstedStatus';
import type { ComparisonData, OfstedCurrentGrade, OfstedInspection, School } from '@/lib/types';
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
const GRADE_TONE: Record<number, 'good' | 'warn' | 'bad'> = {
@@ -41,7 +39,7 @@ function yearsSince(iso: string | null): number | null {
return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
}
function ResultCell({ display, current }: { display: OfstedDisplay; current: OfstedCurrentGrade | null }) {
function ResultCell({ display }: { display: OfstedDisplay }) {
if (display.kind === 'none') {
return <span className={s.small}>No inspection outcome in our dataset</span>;
}
@@ -53,13 +51,15 @@ function ResultCell({ display, current }: { display: OfstedDisplay; current: Ofs
</>
);
}
if (display.kind === 'no_overall_grade') {
if (display.kind === 'transitional') {
return (
<>
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
No overall grade
</span>
<span className={s.small}>Ofsted stopped giving overall grades in September 2024</span>
<span className={s.small}>
Inspected under transitional framework (sub-judgements only)
</span>
</>
);
}
@@ -68,7 +68,11 @@ function ResultCell({ display, current }: { display: OfstedDisplay; current: Ofs
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
{display.gradeLabel}
</span>
{current && <span className={s.small}>{gradeSourceLine(current)}</span>}
<span className={s.small}>
{display.carriedForward
? 'Grade carried forward from an earlier inspection (ungraded visit since)'
: 'Overall grade (older-style inspection)'}
</span>
</>
);
}
@@ -175,7 +179,7 @@ export function CompareOfsted({
<Measure label="Result">
{schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}>
<ResultCell display={displays[i]} current={data[String(school.urn)]?.ofsted?.current_grade ?? null} />
<ResultCell display={displays[i]} />
</Cell>
))}
</Measure>
@@ -183,15 +187,19 @@ export function CompareOfsted({
<Measure label="Inspected">
{schools.map((school, i) => {
const ofsted = data[String(school.urn)]?.ofsted;
// The school's latest visit of any kind (a report card's own date
// when there is one), never an older inspection a grade comes from.
const dateIso = ofsted?.latest_visit?.date ?? ofsted?.rc_inspection_date ?? null;
// A report card is dated by its OWN inspection date. The legacy
// inspection_date belongs to an older inspection and must never
// be shown against a report card (report cards exist only from
// Nov 2025).
const dateIso =
displays[i].kind === 'report_card'
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
const age = yearsSince(dateIso);
return (
<Cell key={school.urn} school={school} index={i}>
{formatInspectionDate(dateIso)}{' '}
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
{ofsted?.latest_visit?.outcome && <span className={s.small}>{ofsted.latest_visit.outcome}</span>}
</Cell>
);
})}
@@ -1,7 +1,8 @@
.heading { font-family: var(--font-display); font-size: 1.4rem; letter-spacing: -0.4px; margin: 0; }
.lede { margin: 0.5rem 0 1.25rem; color: var(--text-secondary); max-width: 64ch; }
.caption { margin: 1rem 0 0; font-size: 0.72rem; color: var(--text-muted); }
.top { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; }
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.arrows { display: flex; gap: 0.5rem; flex: none; }
.arrow { width: 44px; height: 44px; display: grid; place-items: center; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--bg-card); color: var(--brand); }
.arrow:hover:not(:disabled) { border-color: var(--brand); background: var(--brand-bg); }
@@ -14,10 +15,10 @@
.scroller { display: grid; grid-auto-flow: column; grid-auto-columns: calc((100% - 1.8rem) / 3); gap: 0.9rem; overflow-x: auto; scroll-snap-type: x mandatory; padding: 2px; margin: -2px; list-style: none; scrollbar-width: none; -ms-overflow-style: none; }
.scroller::-webkit-scrollbar { display: none; }
@media (max-width: 820px) { .scroller { grid-auto-columns: calc((100% - 0.9rem) / 2); } }
/* Touch widths (MOBILE.md): the arrows would take 96px from a 328px card, for
a control swiping already provides. They go, and the documented right-edge
fade carries the affordance — lifting at the end of the travel, where there
is nothing more to hint at. */
/* Touch widths (MOBILE.md): the arrows would take 96px from a 328px card and
crush the lede into four lines, for a control swiping already provides. They
go, and the documented right-edge fade carries the affordance — lifting at
the end of the travel, where there is nothing more to hint at. */
@media (max-width: 640px) {
.top { display: block; }
.arrows { display: none; }
@@ -4,7 +4,7 @@
* The scroller and its arrows.
*
* `children` are the server-rendered cards and `header` the server-rendered
* heading: both stay server components, passed through, so this file
* heading and lede: both stay server components, passed through, so this file
* owns a DOM ref and nothing else. That is what keeps all six links in the
* initial HTML — a carousel that mounted cards on click would put four of the
* six beyond a crawler and beyond a reader with no JavaScript.
@@ -13,10 +13,8 @@
* reached on their behalf.
*
* There is deliberately no "how these are chosen" panel: the method is already
* visible in the chips and the distances. The single caption line is not a
* method note — it is the one thing a card cannot self-correct.
*
* Nor is there a lede: "Other primary schools near X" only restated the heading.
* visible in the lede, the chips and the distances. The single caption line is
* not a method note — it is the one thing a card cannot self-correct.
*/
import Link from 'next/link';
@@ -34,6 +32,28 @@ export function shouldRenderNearby(nearby?: NearbySchool[] | null): boolean {
return (nearby?.length ?? 0) >= MINIMUM;
}
/**
* What the lede calls the set of schools it is showing.
*
* Derived from the school's own GIAS phase rather than the template it renders
* with, because those disagree for "16 plus" (GIAS phase 6): a sixth-form
* college renders the primary template — computeSchoolFlags tests for the
* substring "secondary" — while the backend correctly matches it against the
* secondary group. Taking the noun from the template would print "Other primary
* schools near <sixth form college>" above a row of secondaries.
*
* A 16-plus school's candidates span the whole secondary group, so no single
* noun fits and it gets the honest general one.
*/
export function nearbyNoun(phase: string | null | undefined): string {
const text = (phase ?? '').trim().toLowerCase();
if (text === 'all-through') return 'all-through schools';
if (text === '16 plus') return 'schools and colleges';
if (text.includes('secondary')) return 'secondary schools';
if (text.includes('primary')) return 'primary schools';
return 'schools';
}
function metricLabel(key: string): string {
return key === 'attainment_8_score' ? 'Attainment 8' : 'Reading, writing & maths';
}
@@ -45,16 +65,25 @@ function formatMetric(value: number | null, key: string): string {
export function NearbySchoolsSection({
urn,
schoolName,
phase,
thisMetricValue,
nearby,
}: {
urn: number;
schoolName: string;
/** The school's own GIAS phase, not the template it renders with. */
phase: string | null | undefined;
thisMetricValue: number | null;
nearby?: NearbySchool[] | null;
}) {
if (!shouldRenderNearby(nearby)) return null;
const schools = nearby as NearbySchool[];
// One card matched on phase alone, so the section may not claim the set
// shares an intake with this school.
const metricKey = schools[0].metric_key;
const noun = nearbyNoun(phase);
return (
<Section id="nearby">
@@ -62,9 +91,12 @@ export function NearbySchoolsSection({
count={schools.length}
labelledBy="nearby-schools-heading"
header={
<h2 id="nearby-schools-heading" className={styles.heading}>
Other schools nearby
</h2>
<div>
<h2 id="nearby-schools-heading" className={styles.heading}>
Other schools nearby
</h2>
<p className={styles.lede}>{`Other ${noun} near ${schoolName}.`}</p>
</div>
}
>
{schools.map((school) => (
+59 -50
View File
@@ -1,18 +1,16 @@
/**
* OfstedSection — shared between the primary and secondary detail pages.
*
* The headline is the school's current Ofsted status (backend:
* fact_ofsted_latest): the report card; or the overall grade still in force,
* with the inspection that awarded or confirmed it; or "No overall grade".
* A later visit that is not the grade's source gets its own line. Both pages
* render the same branches, so neither drops an area judgement (audit M2).
* Rule: docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md
* The two versions were ~80% identical, but that figure masked a real fork in
* the no-overall-grade case: the primary page shows a "Not rated" badge, while
* the secondary page shows a four-area OEIF panel. The disclaimer copy also
* differs slightly. Both are preserved exactly via the `variant` prop rather
* than reconciled, because this refactor must not change either page. Merging
* them is a follow-up decision for a human, not a side effect of a move.
*
* Server component.
*/
import { ofstedDisplay } from '@/lib/compareLogic';
import { formatOfstedDate, gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
import type { OfstedInspection } from '@/lib/types';
import { Section, sectionStyles as styles } from './sectionShared';
@@ -42,13 +40,14 @@ export interface OfstedSectionProps {
ofstedInspectedDate: string | null;
oeifAllSameGrade: boolean;
oeifAreas: { label: string; value: number }[];
variant?: 'primary' | 'secondary';
}
export function OfstedSection({
ofsted, urn, isReportCard, ofstedInspectedDate,
oeifAllSameGrade, oeifAreas,
oeifAllSameGrade, oeifAreas, variant = 'primary',
}: OfstedSectionProps) {
const display = ofstedDisplay(ofsted);
const isSecondary = variant === 'secondary';
return (
<Section id="ofsted">
@@ -56,7 +55,7 @@ export function OfstedSection({
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofstedInspectedDate && (
<span className={styles.ofstedDate}>
Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
{isSecondary && ' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
)}
<a
@@ -99,55 +98,65 @@ export function OfstedSection({
})}
</div>
</>
) : (
) : (!isSecondary || ofsted.overall_effectiveness) ? (
/* ── Old OEIF layout ── */
<>
<div className={styles.ofstedHeader}>
{display.kind === 'graded' || display.kind === 'confirmed' ? (
<>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${display.grade}`]}`}>
{display.gradeLabel}
</span>
{ofsted.previous_overall != null && ofsted.previous_overall !== display.grade && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</>
) : (
<span className={styles.ofstedGrade}>No overall grade</span>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
</span>
{ofsted.previous_overall != null &&
ofsted.previous_overall !== ofsted.overall_effectiveness && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</div>
<p className={styles.ofstedDisclaimer}>
{ofsted.current_grade
? gradeSourceLine(ofsted.current_grade)
: 'Ofsted stopped giving overall grades in September 2024.'}
{ofsted.grade_source === 'ungraded_carried_forward'
? 'This overall grade is carried forward from an earlier inspection. Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
: isSecondary
? 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}
</p>
{showLatestVisitLine(ofsted) && ofsted.latest_visit && (
<p className={styles.ofstedDisclaimer}>Latest visit: {latestVisitLine(ofsted.latest_visit)}</p>
)}
{oeifAllSameGrade && (display.kind === 'graded' || display.kind === 'confirmed') ? (
{oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}>
Rated <strong>{display.gradeLabel}</strong> across all inspected areas: Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas: Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p>
) : oeifAreas.length > 0 ? (
<>
{ofsted.inspection_date && ofsted.inspection_date !== ofsted.latest_visit?.date && (
<p className={styles.ofstedDisclaimer}>
Area judgements from the graded inspection, {formatOfstedDate(ofsted.inspection_date)}.
</p>
)}
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{oeifAreas.map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
))}
</div>
))}
</div>
)}
</>
) : (
/* ── Secondary only: inspected since Sept 2024, no overall grade ── */
<>
<p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade.
</p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
{ label: 'Personal Development', value: ofsted.personal_development },
{ label: 'Leadership & Management', value: ofsted.leadership_management },
].filter(({ value }) => value != null).map(({ label, value }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value!]}
</div>
</div>
</>
) : null}
))}
</div>
</>
)}
</Section>
@@ -55,17 +55,15 @@ export function PrimarySchoolSections({
const secondaryAvg = nationalAvg?.secondary ?? {};
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
// Report cards are dated by their own inspection (rc_inspection_date);
// anything else by the school's latest visit, never the older inspection a
// grade may come from.
// Report cards are dated by their own inspection (rc_inspection_date), never
// the legacy inspection_date (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.latest_visit?.date ?? null;
: ofsted?.inspection_date ?? null;
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
ofsted.overall_effectiveness != null &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
@@ -79,6 +77,7 @@ export function PrimarySchoolSections({
ofstedInspectedDate={ofstedInspectedDate}
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
variant="primary"
/>
)}
@@ -155,6 +154,8 @@ export function PrimarySchoolSections({
{/* Last: it is where the reader goes next, not part of this school. */}
<NearbySchoolsSection
urn={schoolInfo.urn}
schoolName={schoolInfo.school_name}
phase={schoolInfo.phase}
thisMetricValue={flags.latestResults?.rwm_expected_pct ?? null}
nearby={nearbySchools}
/>
@@ -63,12 +63,11 @@ export function SecondarySchoolSections({
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.latest_visit?.date ?? null;
: ofsted?.inspection_date ?? null;
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
const oeifAllSameGrade =
!!ofsted &&
!isReportCard &&
ofsted.overall_effectiveness != null &&
oeifAreas.length >= 3 &&
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
@@ -82,6 +81,7 @@ export function SecondarySchoolSections({
ofstedInspectedDate={ofstedInspectedDate}
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
variant="secondary"
/>
)}
@@ -148,6 +148,8 @@ export function SecondarySchoolSections({
{/* Last: it is where the reader goes next, not part of this school. */}
<NearbySchoolsSection
urn={schoolInfo.urn}
schoolName={schoolInfo.school_name}
phase={schoolInfo.phase}
thisMetricValue={flags.latestResults?.attainment_8_score ?? null}
nearby={nearbySchools}
/>
+15 -14
View File
@@ -100,8 +100,9 @@ export function summariseReportCard(ofsted: OfstedInspection): ReportCardSummary
export type OfstedDisplay =
| { kind: 'none' }
| { kind: 'graded' | 'confirmed'; grade: number; gradeLabel: string; gradeDate: string | null }
| { kind: 'no_overall_grade' }
| { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false }
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
| { kind: 'transitional' }
| { kind: 'report_card'; summary: ReportCardSummary };
export function ofstedDisplay(
@@ -115,19 +116,19 @@ export function ofstedDisplay(
return { kind: 'report_card', summary: summariseReportCard(ofsted) };
}
// The grade still in force (backend: fact_ofsted_latest), never one carried
// past a later inspection that gave none.
const current = ofsted.current_grade;
const gradeLabel = current ? OFSTED_LEGACY_GRADES[current.grade] : undefined;
if (current && gradeLabel !== undefined) {
return {
kind: current.basis === 'confirmed' ? 'confirmed' : 'graded',
grade: current.grade,
gradeLabel,
gradeDate: current.date,
};
const grade = ofsted.overall_effectiveness;
const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined;
if (grade == null || gradeLabel === undefined) {
if (ofsted.inspection_date) {
return { kind: 'transitional' };
}
return { kind: 'none' };
}
return ofsted.latest_visit ? { kind: 'no_overall_grade' } : { kind: 'none' };
if (ofsted.grade_source === 'ungraded_carried_forward') {
return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true };
}
return { kind: 'graded', grade, gradeLabel, carriedForward: false };
}
// ---------------------------------------------------------------------------
-48
View File
@@ -1,48 +0,0 @@
/**
* The sentences the school page and the compare page print about where an
* Ofsted grade came from and what the latest visit was. One wording, two pages.
* Rule: docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md
*/
import type { OfstedCurrentGrade, OfstedInspection, OfstedLatestVisit } from './types';
export function formatOfstedDate(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
}
const VISIT_KIND: Record<OfstedLatestVisit['kind'], string> = {
report_card: 'Report card inspection',
graded: 'Graded inspection',
ungraded: 'Ungraded inspection',
};
/** "Graded inspection, 6 July 2016" or "Confirmed at an ungraded inspection, 14 March 2023". */
export function gradeSourceLine(current: OfstedCurrentGrade): string {
const when = formatOfstedDate(current.date);
const what = current.basis === 'confirmed' ? 'Confirmed at an ungraded inspection' : 'Graded inspection';
return when ? `${what}, ${when}` : what;
}
/** "Ungraded inspection, 13 November 2024: Standards maintained". */
export function latestVisitLine(visit: OfstedLatestVisit): string {
const head = `${VISIT_KIND[visit.kind]}, ${formatOfstedDate(visit.date)}`;
return visit.outcome ? `${head}: ${visit.outcome}` : head;
}
/**
* Whether the latest visit needs its own line: when it is not where the grade
* came from, or when there is no grade but an ungraded outcome to report. A
* graded visit without a grade is already dated by the section title.
*/
export function showLatestVisitLine(
ofsted: Pick<OfstedInspection, 'current_grade' | 'latest_visit'>,
): boolean {
const visit = ofsted.latest_visit;
if (!visit || visit.kind === 'report_card') return false;
const current = ofsted.current_grade;
if (current) return current.date !== visit.date;
return visit.kind === 'ungraded';
}
+3 -23
View File
@@ -76,14 +76,10 @@ export interface School {
parliamentary_constituency?: string | null;
// Ofsted (for list view — summary only)
/** The overall grade still in force; null when the latest inspection gave none. */
ofsted_grade?: 1 | 2 | 3 | 4 | null;
/** Date the grade was awarded or confirmed (null without a grade). */
ofsted_grade_date?: string | null;
/** Report-card inspection date (Nov 2025+); non-null identifies a report
* card in the list/map, where the full report_card object isn't available. */
ofsted_rc_date?: string | null;
/** The school's latest inspection of any kind. */
ofsted_date?: string | null;
ofsted_framework?: string | null;
}
@@ -99,7 +95,6 @@ export interface OfstedInspection {
rc_inspection_date?: string | null;
inspection_type: string | null;
// OEIF fields (old framework, pre-Nov 2025)
/** The graded inspection's own overall grade; never carried forward. */
overall_effectiveness: 1 | 2 | 3 | 4 | null;
quality_of_education: number | null;
behaviour_attitudes: number | null;
@@ -120,12 +115,9 @@ export interface OfstedInspection {
rc_leadership_governance: number | null;
rc_early_years: number | null;
rc_sixth_form: number | null;
/** The overall grade still in force, dated by the inspection that awarded
* ("graded") or confirmed ("confirmed", an ungraded visit) it. Null when the
* latest inspection gave no overall grade, or for a report card. */
current_grade?: OfstedCurrentGrade | null;
/** The school's most recent inspection of any kind. */
latest_visit?: OfstedLatestVisit | null;
/** Where the effective overall grade came from: a graded (Section 5)
* inspection, or carried forward from an ungraded (Section 8) outcome. */
grade_source?: 'graded' | 'ungraded_carried_forward' | null;
/** Renewed-framework (Nov 2025) area judgements, coded + labelled by the
* backend from the live-sampled Ofsted vocabulary. Empty when the school
* has no report-card inspection. Safeguarding is never included here. */
@@ -135,18 +127,6 @@ export interface OfstedInspection {
report_url?: string | null;
}
export interface OfstedCurrentGrade {
grade: 1 | 2 | 3 | 4;
date: string | null;
basis: 'graded' | 'confirmed';
}
export interface OfstedLatestVisit {
date: string;
kind: 'report_card' | 'graded' | 'ungraded';
outcome: string | null;
}
export interface ReportCardEntry {
code: number;
label: string;
+180 -16
View File
@@ -2,7 +2,7 @@
* Utility functions for SchoolCompare
*/
import type { School, MetricDefinition } from './types';
import type { School, MetricDefinition, OfstedInspection, SchoolAdmissions, SchoolResult } from './types';
// ============================================================================
// String Utilities
@@ -619,6 +619,172 @@ export function getCurrentAcademicYear(): number {
return month >= 8 ? year : year - 1;
}
// ============================================================================
// School Detail Hero Helpers
// ============================================================================
const OFSTED_OEIF_WORDS: Record<number, string> = {
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
};
/**
* Format an Ofsted inspection date as "Month YYYY" (e.g. "November 2023").
*/
function formatOfstedMonth(date: string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
}
export type HeroTone = 'teal' | 'green' | 'gold' | 'coral' | 'neutral';
export interface OfstedHeroChip {
state: 'oeif' | 'reportCard' | 'none';
title: string; // Main label (e.g. "Ofsted Outstanding", "Ofsted Report Card")
subtitle: string; // Context line (e.g. "Inspected November 2023")
detail?: string; // Optional extra line (e.g. "Safeguarding: Met")
tone: HeroTone; // Maps to dedicated hero tone classes (not badge classes)
}
/**
* Build the hero-strip Ofsted chip, branching on the inspection framework.
* Never synthesises a single overall grade for ReportCard schools.
*
* Note: the API may return ``framework`` as a literal string ``"NULL"`` for
* older inspections, so we explicitly only branch into the ReportCard layout
* when the value is exactly ``"ReportCard"``. Anything else with an
* ``overall_effectiveness`` score is treated as OEIF.
*/
export function buildOfstedHeroChip(ofsted: OfstedInspection | null | undefined): OfstedHeroChip {
if (!ofsted) {
return {
state: 'none',
title: 'Ofsted pending',
subtitle: 'No inspection on record',
tone: 'neutral',
};
}
const when = formatOfstedMonth(ofsted.inspection_date);
// ReportCard branch — only if the API explicitly says so
if (ofsted.framework === 'ReportCard') {
const safeguarding = ofsted.rc_safeguarding_met;
return {
state: 'reportCard',
title: 'Ofsted Report Card',
subtitle: when ? `Inspected ${when}` : 'New framework inspection',
detail:
safeguarding == null
? undefined
: safeguarding ? 'Safeguarding: Met' : 'Safeguarding: Not met',
tone: safeguarding === false ? 'coral' : 'green',
};
}
// Otherwise treat as OEIF (covers framework === 'OEIF', null, "NULL", etc.)
const grade = ofsted.overall_effectiveness;
if (grade && OFSTED_OEIF_WORDS[grade]) {
const oeifTone: HeroTone =
grade === 1 ? 'teal' :
grade === 2 ? 'green' :
grade === 3 ? 'gold' :
'coral';
return {
state: 'oeif',
title: `Ofsted ${OFSTED_OEIF_WORDS[grade]}`,
subtitle: when ? `Inspected ${when}` : 'Inspected',
tone: oeifTone,
};
}
return {
state: 'oeif',
title: 'Ofsted inspected',
subtitle: when ? `Inspected ${when}` : 'Inspection on record',
tone: 'neutral',
};
}
/**
* Build a one-sentence editorial summary for the school detail hero.
* Branches on Ofsted framework so Report Card schools are never described
* with an overall grade they do not have.
*/
export function buildSchoolSummary(
schoolInfo: School,
ofsted: OfstedInspection | null | undefined,
admissions: SchoolAdmissions | null | undefined,
latestResults: SchoolResult | null | undefined,
): string {
const parts: string[] = [];
// Size descriptor
const pupils = latestResults?.total_pupils ?? schoolInfo.total_pupils ?? null;
const sizeWord =
pupils == null ? '' :
pupils < 200 ? 'Small' :
pupils < 500 ? 'Mid-sized' :
'Large';
// Phase descriptor — avoid the raw code
const phase = (schoolInfo.phase ?? '').toLowerCase();
const phaseWord =
phase.includes('secondary') ? 'secondary' :
phase === 'all-through' ? 'all-through' :
phase.includes('primary') ? 'primary' :
'school';
// Religious character
const religion = schoolInfo.religious_denomination;
const religionWord =
!religion || /none|does not apply/i.test(religion) ? '' :
/roman catholic|catholic/i.test(religion) ? 'Catholic ' :
/church of england|ce|anglican/i.test(religion) ? 'Church of England ' :
/jewish/i.test(religion) ? 'Jewish ' :
/muslim|islam/i.test(religion) ? 'Muslim ' :
/hindu/i.test(religion) ? 'Hindu ' :
/sikh/i.test(religion) ? 'Sikh ' :
'';
// Locality — prefer town from address parsing (fallback to LA)
const locality = schoolInfo.town || schoolInfo.local_authority || '';
const lead = [sizeWord, religionWord + phaseWord].filter(Boolean).join(' ');
let opening = lead || 'School';
if (locality) opening += ` in ${locality}`;
parts.push(opening);
// Ofsted clause (framework-aware)
if (ofsted?.framework === 'OEIF' && ofsted.overall_effectiveness) {
parts.push(`rated ${OFSTED_OEIF_WORDS[ofsted.overall_effectiveness]} by Ofsted`);
} else if (ofsted?.framework === 'ReportCard') {
const when = formatOfstedMonth(ofsted.inspection_date);
parts.push(
when
? `most recently inspected under Ofsted's Report Card framework in ${when}`
: "recently inspected under Ofsted's new Report Card framework",
);
}
// Admissions clause
if (admissions?.oversubscribed) {
if (admissions.first_preference_offer_pct != null) {
const pct = Math.round(admissions.first_preference_offer_pct);
parts.push(
`oversubscribed (${pct}% of first-choice applicants are offered a place)`,
);
} else {
parts.push('oversubscribed');
}
} else if (admissions?.first_preference_offer_pct != null && admissions.first_preference_offer_pct >= 90) {
parts.push('most families get their first-choice offer');
}
return parts.join(', ') + '.';
}
// ─── Legacy (OEIF) sub-judgement areas ────────────────────────────────────────
export interface OfstedLegacyArea {
@@ -671,18 +837,15 @@ export interface OfstedListBadge {
* Checked FIRST so it wins over any carried-forward legacy grade — the
* list has no full report_card object, and ofsted_framework is the raw
* event grouping ("Schools - S5"), never "ReportCard".
* - Current grade (ofsted_grade set): grade word + the year it was awarded
* or confirmed (ofsted_grade_date), colour-keyed. Never the year of a later
* visit: that paired old grades with new inspections (audit C1).
* - Inspected with no grade in force (every inspection Sept 2024 – Nov 2025,
* or an ungraded visit whose outcome names no grade): "Inspected · YYYY",
* dated by the latest visit, so a school never reads as both inspected and
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the
* detail page's hero chip so a school never reads as both inspected and
* "Not yet inspected"
* - No inspection on record: "Not yet inspected" in grey
*/
export function buildOfstedListBadge(school: {
ofsted_grade?: 1 | 2 | 3 | 4 | null;
ofsted_grade_date?: string | null;
ofsted_date?: string | null;
ofsted_framework?: string | null;
ofsted_rc_date?: string | null;
@@ -695,7 +858,10 @@ export function buildOfstedListBadge(school: {
return { label: `Report Card · ${rcYear}`, cssClass: 'ofstedRc' };
}
const yearOf = (iso?: string | null) => (iso ? new Date(iso).getFullYear() : null);
const year = school.ofsted_date
? new Date(school.ofsted_date).getFullYear()
: null;
const yearStr = year ? ` · ${year}` : '';
if (school.ofsted_grade) {
const labels: Record<number, string> = {
@@ -704,19 +870,17 @@ export function buildOfstedListBadge(school: {
3: 'Req. Improvement',
4: 'Inadequate',
};
const gradeYear = yearOf(school.ofsted_grade_date);
return {
label: `${labels[school.ofsted_grade]}${gradeYear ? ` · ${gradeYear}` : ''}`,
label: `${labels[school.ofsted_grade]}${yearStr}`,
cssClass: `ofsted${school.ofsted_grade}`,
};
}
// An inspection is on record but no overall grade is in force: every
// inspection from Sept 2024 to Nov 2025, or an ungraded visit whose outcome
// names no grade. Dated by the latest visit.
// An inspection is on record (date or framework present) but carries no
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
// that has genuinely never been inspected.
if (school.ofsted_date != null || school.ofsted_framework != null) {
const visitYear = yearOf(school.ofsted_date);
return { label: `Inspected${visitYear ? ` · ${visitYear}` : ''}`, cssClass: 'ofstedInspected' };
return { label: `Inspected${yearStr}`, cssClass: 'ofstedInspected' };
}
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
@@ -0,0 +1,26 @@
"""Read a GIAS extract from the raw bytes of the download.
GIAS writes its CSVs in Windows-1252 and sends no charset, so `resp.text`
leaves requests to guess the codec. On 3 Oct 2026 it guessed windows-1250 and
"à" became "ŕ". Decode the bytes ourselves instead.
"""
from __future__ import annotations
import io
import pandas as pd
GIAS_ENCODING = "cp1252"
def read_gias_csv(content: bytes, logger=None) -> pd.DataFrame:
"""Every column as a string; a blank cell stays ''."""
# Windows-1252 leaves five bytes undefined. One stray byte must not stop
# the daily refresh of every school, so it becomes U+FFFD and is logged.
text = content.decode(GIAS_ENCODING, errors="replace")
undecodable = text.count("�")
if undecodable and logger is not None:
logger.warning("%d byte(s) in the GIAS extract could not be decoded as %s",
undecodable, GIAS_ENCODING)
return pd.read_csv(io.StringIO(text), dtype=str, keep_default_na=False)
@@ -7,6 +7,8 @@ from datetime import date, timedelta
from singer_sdk import Stream, Tap
from singer_sdk import typing as th
from tap_uk_gias.gias_csv import read_gias_csv
GIAS_URL_TEMPLATE = (
"https://ea-edubase-api-prod.azurewebsites.net"
"/edubase/downloads/public/edubasealldata{date}.csv"
@@ -74,9 +76,6 @@ class GIASEstablishmentsStream(Stream):
def get_records(self, context):
"""Download GIAS CSV and yield rows."""
import io
import pandas as pd
import requests
today = date.today()
@@ -94,12 +93,7 @@ class GIASEstablishmentsStream(Stream):
resp.raise_for_status()
df = pd.read_csv(
io.StringIO(resp.text),
encoding="latin-1",
dtype=str,
keep_default_na=False,
)
df = read_gias_csv(resp.content, self.logger)
for _, row in df.iterrows():
record = row.to_dict()
@@ -126,9 +120,6 @@ class GIASLinksStream(Stream):
def get_records(self, context):
"""Download GIAS links CSV and yield rows."""
import io
import pandas as pd
import requests
today = date.today()
@@ -146,12 +137,7 @@ class GIASLinksStream(Stream):
resp.raise_for_status()
df = pd.read_csv(
io.StringIO(resp.text),
encoding="latin-1",
dtype=str,
keep_default_na=False,
)
df = read_gias_csv(resp.content, self.logger)
for _, row in df.iterrows():
record = row.to_dict()
+66
View File
@@ -0,0 +1,66 @@
"""GIAS publishes its extracts in Windows-1252 and declares no charset.
The tap used to hand pandas `resp.text`, so requests guessed the codec.
On 3 Oct 2026 it guessed windows-1250, and "St Thomas à Becket" was stored
as "St Thomas ŕ Becket". The `encoding=` passed to read_csv did nothing,
because the text was already decoded.
"""
import importlib.util
import logging
from pathlib import Path
import pytest
MODULE = (Path(__file__).resolve().parents[1] / 'plugins' / 'extractors' / 'tap-uk-gias'
/ 'tap_uk_gias' / 'gias_csv.py')
@pytest.fixture
def gias_csv():
spec = importlib.util.spec_from_file_location('gias_csv', MODULE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# Byte for byte as GIAS writes it: 0xE0 à, 0x92 ’, 0xE9 é, 0xB0 °, 0xE7 ç.
EXTRACT = (
b'"URN","EstablishmentName","HeadLastName"\r\n'
b'"138950","St Thomas \xe0 Becket Catholic Secondary School","Smith"\r\n'
b'"100000","The Dean and Chapter of St Paul\x92s Cathedral","Pr\xe9vert"\r\n'
b'"140677","North Star 180\xb0","Fran\xe7ois"\r\n'
b'"100001","No head recorded",""\r\n'
)
def test_names_decode_as_windows_1252(gias_csv):
df = gias_csv.read_gias_csv(EXTRACT)
assert list(df['EstablishmentName']) == [
'St Thomas à Becket Catholic Secondary School',
'The Dean and Chapter of St Paul’s Cathedral',
'North Star 180°',
'No head recorded',
]
assert list(df['HeadLastName']) == ['Smith', 'Prévert', 'François', '']
def test_the_codec_requests_guessed_is_not_used(gias_csv):
# What the tap stored on 3 Oct: the same bytes read as windows-1250.
assert 'ŕ' in EXTRACT.decode('cp1250')
names = ' '.join(gias_csv.read_gias_csv(EXTRACT)['EstablishmentName'])
assert 'ŕ' not in names
def test_values_stay_strings(gias_csv):
df = gias_csv.read_gias_csv(EXTRACT)
assert df.loc[0, 'URN'] == '138950'
def test_a_byte_windows_1252_leaves_undefined_does_not_stop_the_load(gias_csv, caplog):
# 0x81 has no Windows-1252 character. One odd name must not block the daily
# refresh of every school, but it must be visible in the log.
extract = b'"URN","EstablishmentName"\r\n"100002","Odd \x81 Name"\r\n'
with caplog.at_level(logging.WARNING):
df = gias_csv.read_gias_csv(extract, logger=logging.getLogger('gias'))
assert df.loc[0, 'EstablishmentName'] == 'Odd � Name'
assert 'could not be decoded' in caplog.text
@@ -1,91 +1,18 @@
-- Intermediate model: the current Ofsted status per URN
-- One row per school: its latest visit (report card, graded or ungraded
-- inspection) and the overall grade still in force, if any. A grade is dated
-- by the inspection that awarded or confirmed it, never by a later visit.
-- Rule and examples: docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md
-- Intermediate model: Latest Ofsted inspection per URN
-- Picks the most recent inspection for each school
with inspections as (
with ranked as (
select
*,
-- The newest of the three inspections, read from the dates. (Report
-- cards began in Nov 2025, after the last legacy inspections, so today
-- a report card is always the latest; nothing below relies on that.)
greatest(rc_inspection_date, graded_inspection_date, ungraded_inspection_date)
as latest_visit_date
from {{ ref('stg_ofsted_inspections') }}
),
ranked as (
select
*,
-- Monthly loads can leave several rows per school. The newest visit
-- wins; the tie-breaks keep the choice deterministic.
row_number() over (
partition by urn
order by latest_visit_date desc,
rc_inspection_date desc nulls last,
graded_inspection_date desc nulls last,
ungraded_inspection_date desc nulls last
order by inspection_date desc
) as rn
from inspections
),
latest as (
select
*,
-- Same-day ties resolve report card, then graded, then ungraded.
case
when rc_inspection_date = latest_visit_date then 'report_card'
when graded_inspection_date = latest_visit_date then 'graded'
else 'ungraded'
end as latest_visit_kind
from ranked
where rn = 1
),
graded as (
select
*,
-- The grade still in force. A report card replaced overall grades, so
-- none survives it. Otherwise: a graded inspection's own overall grade
-- (1-4; "Not judged" and the sentinel 9 are no grade); else an
-- ungraded visit's "School remains X"; else, after an ungraded visit
-- that names no grade, the graded inspection's grade.
case
when rc_inspection_date is not null
then null
when latest_visit_kind = 'graded' and overall_effectiveness between 1 and 4
then 'graded_latest'
when latest_visit_kind = 'ungraded' and ungraded_grade is not null
then 'confirmed'
when latest_visit_kind = 'ungraded' and overall_effectiveness between 1 and 4
then 'graded_earlier'
end as grade_case
from latest
from {{ ref('stg_ofsted_inspections') }}
)
select
urn,
latest_visit_date,
latest_visit_kind,
case when latest_visit_kind = 'ungraded' then ungraded_outcome end as latest_visit_outcome,
case grade_case
when 'confirmed' then ungraded_grade
when 'graded_latest' then overall_effectiveness
when 'graded_earlier' then overall_effectiveness
end as current_grade,
case grade_case
when 'confirmed' then ungraded_inspection_date
when 'graded_latest' then graded_inspection_date
when 'graded_earlier' then graded_inspection_date
end as current_grade_date,
case grade_case
when 'confirmed' then 'confirmed'
when 'graded_latest' then 'graded'
when 'graded_earlier' then 'graded'
end as current_grade_basis,
graded_inspection_date,
ungraded_inspection_date,
inspection_date,
inspection_type,
framework,
@@ -109,4 +36,5 @@ select
rc_sixth_form,
rc_inspection_date,
report_url
from graded
from ranked
where rn = 1
@@ -1,113 +0,0 @@
version: 2
unit_tests:
- name: graded_not_judged_has_no_grade
description: Rabbsfarm (102408). The 2025 inspection gave no overall grade, so the 2020 "remains Good" is not carried forward (audit C1).
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 102408, graded_inspection_date: '2025-06-17', ungraded_inspection_date: '2020-02-06', overall_effectiveness: null, ungraded_grade: 2, ungraded_outcome: 'School remains Good'}
expect:
rows:
- {urn: 102408, latest_visit_date: '2025-06-17', latest_visit_kind: graded, latest_visit_outcome: null, current_grade: null, current_grade_date: null, current_grade_basis: null}
- name: graded_with_overall_grade
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 1, graded_inspection_date: '2019-06-01', overall_effectiveness: 2}
expect:
rows:
- {urn: 1, latest_visit_date: '2019-06-01', latest_visit_kind: graded, current_grade: 2, current_grade_date: '2019-06-01', current_grade_basis: graded}
- name: ungraded_remains_good_confirms_the_grade
description: Robins Lane (104762). Graded Good 2020, "School remains Good" July 2024 — Good, dated by the confirming visit.
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 104762, graded_inspection_date: '2020-01-07', ungraded_inspection_date: '2024-07-18', overall_effectiveness: 2, ungraded_grade: 2, ungraded_outcome: 'School remains Good'}
expect:
rows:
- {urn: 104762, latest_visit_date: '2024-07-18', latest_visit_kind: ungraded, latest_visit_outcome: 'School remains Good', current_grade: 2, current_grade_date: '2024-07-18', current_grade_basis: confirmed}
- name: post_2024_ungraded_keeps_graded_grade_with_its_own_date
description: Washwood Heath (139888). Graded Good 2020, "Standards maintained" May 2025 — Good, dated 2020; latest visit May 2025 (audit M1).
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 139888, graded_inspection_date: '2020-03-03', ungraded_inspection_date: '2025-05-21', overall_effectiveness: 2, ungraded_grade: null, ungraded_outcome: 'Standards maintained'}
expect:
rows:
- {urn: 139888, latest_visit_date: '2025-05-21', latest_visit_kind: ungraded, latest_visit_outcome: 'Standards maintained', current_grade: 2, current_grade_date: '2020-03-03', current_grade_basis: graded}
- name: ungraded_only_standards_maintained
description: Oakgrove (136454). Only an ungraded visit, outcome names no grade.
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 136454, ungraded_inspection_date: '2024-11-13', ungraded_grade: null, ungraded_outcome: 'Standards maintained'}
expect:
rows:
- {urn: 136454, latest_visit_date: '2024-11-13', latest_visit_kind: ungraded, latest_visit_outcome: 'Standards maintained', current_grade: null, current_grade_date: null, current_grade_basis: null}
- name: report_card_wins
description: The Willink School (110048). A report card is the latest visit and no legacy grade stays in force.
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 110048, ungraded_inspection_date: '2023-10-05', ungraded_grade: 2, ungraded_outcome: 'School remains Good', rc_inspection_date: '2026-05-06', rc_inclusion: 3}
expect:
rows:
- {urn: 110048, latest_visit_date: '2026-05-06', latest_visit_kind: report_card, latest_visit_outcome: null, current_grade: null, current_grade_date: null, current_grade_basis: null}
- name: duplicate_rows_newer_report_card_wins
description: Monthly loads can leave an older row beside a newer one for the same graded date; the row with the report card must win (audit H3).
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 138186, graded_inspection_date: '2023-06-13', overall_effectiveness: 3}
- {urn: 138186, graded_inspection_date: '2023-06-13', overall_effectiveness: 3, rc_inspection_date: '2026-06-02', rc_inclusion: 3}
expect:
rows:
- {urn: 138186, latest_visit_date: '2026-06-02', latest_visit_kind: report_card, current_grade: null}
- name: same_day_graded_wins
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 2, graded_inspection_date: '2024-03-01', ungraded_inspection_date: '2024-03-01', overall_effectiveness: 1, ungraded_grade: 2, ungraded_outcome: 'School remains Good'}
expect:
rows:
- {urn: 2, latest_visit_kind: graded, current_grade: 1, current_grade_basis: graded}
- name: overall_sentinel_is_not_a_grade
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 3, graded_inspection_date: '2018-05-01', overall_effectiveness: 9}
expect:
rows:
- {urn: 3, latest_visit_kind: graded, current_grade: null}
- name: newer_legacy_visit_after_a_report_card
description: >
Not in Ofsted's data today (0 of 2,451 report-card schools in the 31 Aug
2026 MI), but the latest visit is read from the dates, not assumed. The
report card still leaves no legacy grade in force.
model: int_ofsted_latest
given:
- input: ref('stg_ofsted_inspections')
rows:
- {urn: 4, ungraded_inspection_date: '2026-03-02', ungraded_grade: 2, ungraded_outcome: 'School remains Good', rc_inspection_date: '2025-12-01', rc_inclusion: 3}
expect:
rows:
- {urn: 4, latest_visit_date: '2026-03-02', latest_visit_kind: ungraded, latest_visit_outcome: 'School remains Good', current_grade: null, current_grade_date: null, current_grade_basis: null}
@@ -125,30 +125,6 @@ models:
- name: inspection_date
tests: [not_null]
- name: fact_ofsted_latest
description: >
Current Ofsted status, one row per URN: the latest visit and the overall
grade still in force. See int_ofsted_latest for the rule.
columns:
- name: urn
tests: [not_null, unique]
- name: latest_visit_date
tests: [not_null]
- name: latest_visit_kind
tests:
- not_null
- accepted_values:
values: ['report_card', 'graded', 'ungraded']
- name: current_grade
tests:
- accepted_values:
values: [1, 2, 3, 4]
quote: false
- name: current_grade_basis
tests:
- accepted_values:
values: ['graded', 'confirmed']
- name: fact_pupil_characteristics
description: Pupil demographics — one row per URN per year
columns:
@@ -71,12 +71,12 @@ select
s.nursery_provision,
s.admissions_policy_code,
-- Latest Ofsted (populated after monthly Ofsted pipeline runs). The grade
-- still in force and the latest visit, from int_ofsted_latest — never a
-- grade carried past a newer inspection.
-- Latest Ofsted (populated after monthly Ofsted pipeline runs)
{% if ofsted_relation is not none %}
o.current_grade as ofsted_grade,
o.latest_visit_date as ofsted_date,
-- Prefer the graded overall effectiveness; fall back to the grade parsed
-- from the latest ungraded (Section 8) outcome when no graded grade exists.
coalesce(o.overall_effectiveness, o.ungraded_grade) as ofsted_grade,
o.inspection_date as ofsted_date,
o.framework as ofsted_framework
{% else %}
null::text as ofsted_grade,
@@ -1,35 +0,0 @@
version: 2
unit_tests:
- name: dim_school_ofsted_grade_is_the_one_still_in_force
description: >
Rabbsfarm (102408). Its 2025 inspection gave no overall grade, so the 2020
"School remains Good" must not reach dim_school (which feeds Typesense's
rating), and ofsted_date is the latest visit (audit C1, M1).
model: dim_school
given:
- input: ref('stg_gias_establishments')
rows:
- {urn: 102408, school_name: 'Rabbsfarm Primary School', status_code: 1, school_type_code: 1, local_authority_code: 312, phase_code: 2}
- input: ref('int_ofsted_latest')
rows:
- {urn: 102408, inspection_date: '2025-06-17', overall_effectiveness: null, ungraded_grade: 2, framework: 'Schools - S5', latest_visit_date: '2025-06-17', current_grade: null}
expect:
rows:
- {urn: 102408, ofsted_grade: null, ofsted_date: '2025-06-17', ofsted_framework: 'Schools - S5'}
- name: dim_school_ofsted_grade_keeps_its_own_date_out_of_ofsted_date
description: >
Washwood Heath (139888). Good from a 2020 graded inspection; latest visit
an ungraded one in May 2025. ofsted_date is the latest visit.
model: dim_school
given:
- input: ref('stg_gias_establishments')
rows:
- {urn: 139888, school_name: 'Washwood Heath Academy', status_code: 1, school_type_code: 28, local_authority_code: 330, phase_code: 7}
- input: ref('int_ofsted_latest')
rows:
- {urn: 139888, inspection_date: '2020-03-03', overall_effectiveness: 2, ungraded_grade: null, framework: 'Schools - S5', latest_visit_date: '2025-05-21', current_grade: 2}
expect:
rows:
- {urn: 139888, ofsted_grade: 2, ofsted_date: '2025-05-21'}
@@ -1,37 +0,0 @@
-- Mart: current Ofsted status — one row per URN
-- The backend reads this instead of choosing the latest row of
-- fact_ofsted_inspection itself. The rule lives in int_ofsted_latest.
select
urn,
latest_visit_date,
latest_visit_kind,
latest_visit_outcome,
current_grade,
current_grade_date,
current_grade_basis,
graded_inspection_date,
ungraded_inspection_date,
rc_inspection_date,
inspection_type,
framework,
overall_effectiveness,
quality_of_education,
behaviour_attitudes,
personal_development,
leadership_management,
early_years_provision,
sixth_form_provision,
ungraded_outcome,
ungraded_grade,
rc_safeguarding_met,
rc_inclusion,
rc_curriculum_teaching,
rc_achievement,
rc_attendance_behaviour,
rc_personal_development,
rc_leadership_governance,
rc_early_years,
rc_sixth_form,
report_url
from {{ ref('int_ofsted_latest') }}
@@ -1,10 +1,5 @@
-- Staging model: Ofsted inspection records
-- Handles both OEIF (pre-Nov 2025) and Report Card (post-Nov 2025) frameworks.
--
-- Ofsted's MI carries up to three inspections per school: the latest graded
-- one, the latest ungraded one and the latest report card. Their dates stay
-- separate here so int_ofsted_latest can tell which came last and which one a
-- grade belongs to (docs/superpowers/specs/2026-10-05-ofsted-current-status-design.md).
-- Handles both OEIF (pre-Nov 2025) and Report Card (post-Nov 2025) frameworks
with source as (
select * from {{ source('raw', 'ofsted_inspections') }}
@@ -13,12 +8,17 @@ with source as (
renamed as (
select
cast(urn as integer) as urn,
to_date(nullif(trim(inspection_date), 'NULL'), 'DD/MM/YYYY') as graded_inspection_date,
to_date(nullif(trim(ungraded_inspection_date), 'NULL'), 'DD/MM/YYYY') as ungraded_inspection_date,
-- Inspection event date: the graded inspection when present, otherwise the
-- ungraded (Section 8) inspection so schools with only an ungraded
-- inspection are still retained.
coalesce(
to_date(nullif(trim(inspection_date), 'NULL'), 'DD/MM/YYYY'),
to_date(nullif(trim(ungraded_inspection_date), 'NULL'), 'DD/MM/YYYY')
) as inspection_date,
inspection_type,
event_type_grouping as framework,
-- OEIF grades (1-4 scale; 9 = not applicable)
-- OEIF grades (1-4 scale)
{{ safe_numeric('overall_effectiveness') }}::integer as overall_effectiveness,
{{ safe_numeric('quality_of_education') }}::integer as quality_of_education,
{{ safe_numeric('behaviour_and_attitudes') }}::integer as behaviour_attitudes,
@@ -27,9 +27,9 @@ renamed as (
{{ safe_numeric('early_years_provision') }}::integer as early_years_provision,
{{ safe_numeric('sixth_form_provision') }}::integer as sixth_form_provision,
-- Ungraded (Section 8) inspection outcome — free text, plus the grade
-- it confirms ("School remains Good" → 2); null for outcomes that
-- name no grade.
-- Ungraded (Section 8) inspection outcome — free text, plus a grade
-- parsed from it (1/2/null) used as a last-resort fallback for schools
-- with no graded overall effectiveness.
nullif(trim(ungraded_outcome), 'NULL') as ungraded_outcome,
{{ parse_ungraded_outcome('ungraded_outcome') }}::integer as ungraded_grade,
@@ -50,36 +50,31 @@ renamed as (
{{ parse_report_card_grade('rc_sixth_form') }}::integer as rc_sixth_form,
-- Start date of the latest FULL inspection (the report-card
-- inspection in the renewed framework). Only kept when the row
-- carries report-card grades, because in legacy-format files this
-- column is the legacy inspection date.
-- inspection in the renewed framework). Guarded in the final select:
-- only kept when the row actually carries report-card grades, because
-- in legacy-format files this column is the legacy inspection date.
to_date(nullif(trim(rc_inspection_date), 'NULL'), 'DD/MM/YYYY') as rc_inspection_date_raw,
nullif(trim(report_url), 'NULL') as report_url
from source
where urn is not null
),
dated as (
select
*,
case
when rc_safeguarding_met is not null
or rc_inclusion is not null
or rc_curriculum_teaching is not null
or rc_achievement is not null
or rc_attendance_behaviour is not null
or rc_personal_development is not null
or rc_leadership_governance is not null
then rc_inspection_date_raw
end as rc_inspection_date
from renamed
and (
nullif(trim(inspection_date), 'NULL') is not null
or nullif(trim(ungraded_inspection_date), 'NULL') is not null
)
)
select
*,
-- For readers that predate the separate dates (fact_ofsted_inspection and
-- the backend until it reads fact_ofsted_latest). Never null below.
coalesce(graded_inspection_date, ungraded_inspection_date, rc_inspection_date) as inspection_date
from dated
where coalesce(graded_inspection_date, ungraded_inspection_date, rc_inspection_date) is not null
case
when rc_safeguarding_met is not null
or rc_inclusion is not null
or rc_curriculum_teaching is not null
or rc_achievement is not null
or rc_attendance_behaviour is not null
or rc_personal_development is not null
or rc_leadership_governance is not null
then rc_inspection_date_raw
end as rc_inspection_date
from renamed
where inspection_date is not null
@@ -1,25 +0,0 @@
version: 2
unit_tests:
- name: stg_ofsted_keeps_the_three_dates_apart
description: The graded and ungraded dates must stay separate so the latest visit can be found.
model: stg_ofsted_inspections
given:
- input: source('raw', 'ofsted_inspections')
rows:
- {urn: 102408, inspection_date: '17/06/2025', ungraded_inspection_date: '06/02/2020', overall_effectiveness: 'Not judged', ungraded_outcome: 'School remains Good', rc_inspection_date: '17/06/2025', rc_inclusion: 'NULL'}
expect:
rows:
- {urn: 102408, graded_inspection_date: '2025-06-17', ungraded_inspection_date: '2020-02-06', rc_inspection_date: null, inspection_date: '2025-06-17', ungraded_grade: 2}
- name: stg_ofsted_keeps_report_card_only_rows
description: A school whose only inspection is a report card used to be dropped (audit H3).
model: stg_ofsted_inspections
given:
- input: source('raw', 'ofsted_inspections')
rows:
- {urn: 149612, inspection_date: 'NULL', ungraded_inspection_date: 'NULL', rc_inspection_date: '10/02/2026', rc_inclusion: 'Expected standard', rc_safeguarding_met: 'Met'}
- {urn: 1, inspection_date: 'NULL', ungraded_inspection_date: 'NULL', rc_inspection_date: 'NULL'}
expect:
rows:
- {urn: 149612, graded_inspection_date: null, ungraded_inspection_date: null, rc_inspection_date: '2026-02-10', inspection_date: '2026-02-10', rc_inclusion: 3}
@@ -1,10 +0,0 @@
-- A grade is dated by the inspection that awarded or confirmed it, never
-- after the latest visit, and has a date and a basis exactly when it exists.
-- A report card leaves no legacy grade in force.
select urn
from {{ ref('fact_ofsted_latest') }}
where current_grade_date > latest_visit_date
or (current_grade is null) <> (current_grade_date is null)
or (current_grade is null) <> (current_grade_basis is null)
or (rc_inspection_date is not null and current_grade is not null)