From 96732c56d16f2afcc72a5be01af5ab5122696463 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 5 Oct 2026 16:26:51 +0100 Subject: [PATCH 1/7] fix(pipeline): dim_school's Ofsted grade is the one still in force dim_school.ofsted_grade (Typesense's rating) coalesced the graded grade with an older ungraded visit's, so Rabbsfarm's 2020 'remains Good' survived its 2025 no-grade inspection. It now reads int_ofsted_latest's current_grade, and ofsted_date is the latest visit. Unit tests pin both (audit C1, M1). Co-Authored-By: Claude Opus 5.5 --- .../transform/models/marts/dim_school.sql | 10 +++--- .../transform/models/marts/dim_school.yml | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 pipeline/transform/models/marts/dim_school.yml diff --git a/pipeline/transform/models/marts/dim_school.sql b/pipeline/transform/models/marts/dim_school.sql index fe7702d..f11f5c8 100644 --- a/pipeline/transform/models/marts/dim_school.sql +++ b/pipeline/transform/models/marts/dim_school.sql @@ -71,12 +71,12 @@ select s.nursery_provision, s.admissions_policy_code, - -- Latest Ofsted (populated after monthly Ofsted pipeline runs) + -- 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. {% if ofsted_relation is not none %} - -- 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.current_grade as ofsted_grade, + o.latest_visit_date as ofsted_date, o.framework as ofsted_framework {% else %} null::text as ofsted_grade, diff --git a/pipeline/transform/models/marts/dim_school.yml b/pipeline/transform/models/marts/dim_school.yml new file mode 100644 index 0000000..8b5d555 --- /dev/null +++ b/pipeline/transform/models/marts/dim_school.yml @@ -0,0 +1,35 @@ +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'} -- 2.54.0 From a9e3a6a700171ba9b1e4b750c67712d57d2016c1 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 5 Oct 2026 16:28:30 +0100 Subject: [PATCH 2/7] fix(backend): serve the current Ofsted status from fact_ofsted_latest The list query and the batch Ofsted fetch each picked 'the latest row' of fact_ofsted_inspection themselves, and _ofsted_block carried an older ungraded grade forward when the latest graded inspection gave none. All three now read marts.fact_ofsted_latest. List rows: ofsted_grade is the grade still in force, ofsted_grade_date when it was awarded or confirmed, ofsted_date the latest visit. The ofsted block gains current_grade and latest_visit and loses grade_source; overall_effectiveness is the graded inspection's own result. A school with only an inspection stays publishable in the sitemap. Requires fact_ofsted_latest (PR #183's pipeline run) on the database. Co-Authored-By: Claude Opus 5.5 --- backend/app.py | 5 +- backend/data_loader.py | 93 ++++++++----------- backend/models.py | 43 +++++++++ backend/schemas.py | 1 + backend/tests/test_compare_enrichment.py | 5 +- backend/tests/test_ofsted_status_payload.py | 56 +++++++++++ backend/tests/test_supplementary_batch.py | 18 ++-- .../tests/test_supplementary_enrichment.py | 45 +++++++-- 8 files changed, 188 insertions(+), 78 deletions(-) create mode 100644 backend/tests/test_ofsted_status_payload.py diff --git a/backend/app.py b/backend/app.py index d0019e5..de856b2 100644 --- a/backend/app.py +++ b/backend/app.py @@ -97,8 +97,9 @@ 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. -_PUBLISHABLE_FIELDS = ("rwm_expected_pct", "attainment_8_score", "ofsted_grade") +# _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") def _has_publishable_data(row) -> bool: diff --git a/backend/data_loader.py b/backend/data_loader.py index 79ba386..bdfa8d6 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -18,7 +18,7 @@ from .config import settings from .database import SessionLocal, engine from .models import ( DimSchool, DimLocation, KS2Performance, - FactOfstedInspection, FactAdmissions, FactAdmissionDistance, + FactOfstedLatest, FactAdmissions, FactAdmissionDistance, FactDeprivation, FactFinance, FactPupilCharacteristics, FactKs4Destinations, FactKs5Destinations, ) @@ -251,10 +251,11 @@ _MAIN_QUERY = text(""" s.website, s.telephone, s.nursery_provision, - foi.ofsted_grade, - foi.ofsted_date, - foi.ofsted_framework, - foi.ofsted_rc_date, + 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, l.local_authority_name AS local_authority, l.local_authority_code, l.address_line1 AS address1, @@ -335,21 +336,10 @@ _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 - 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 + -- 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 ORDER BY s.school_name, p.year """) @@ -731,39 +721,36 @@ 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 the latest Ofsted inspection row for API responses. + """Serialize a fact_ofsted_latest row for API responses. - `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. + `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. """ - 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": 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_date": _iso(o.graded_inspection_date), + "rc_inspection_date": _iso(o.rc_inspection_date), "inspection_type": o.inspection_type, - "overall_effectiveness": overall, - "grade_source": grade_source, + "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 + ), "quality_of_education": o.quality_of_education, "behaviour_attitudes": o.behaviour_attitudes, "personal_development": o.personal_development, @@ -1097,20 +1084,14 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict: logging.getLogger(__name__).error("batch supplementary query failed: %s", e) db.rollback() - # Ofsted — latest inspection per URN. Ordered so the first row seen per - # URN is the most recent. + # Ofsted — the mart already holds one current status per URN. def _ofsted(): rows = ( - db.query(FactOfstedInspection) - .filter(FactOfstedInspection.urn.in_(urns)) - .order_by(FactOfstedInspection.urn, FactOfstedInspection.inspection_date.desc()) + db.query(FactOfstedLatest) + .filter(FactOfstedLatest.urn.in_(urns)) .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) diff --git a/backend/models.py b/backend/models.py index d090d8f..2fb6f74 100644 --- a/backend/models.py +++ b/backend/models.py @@ -162,6 +162,49 @@ 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" diff --git a/backend/schemas.py b/backend/schemas.py index 0fd25e4..fad8f48 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -574,6 +574,7 @@ SCHOOL_COLUMNS = [ "gender", "admissions_policy", "ofsted_grade", + "ofsted_grade_date", "ofsted_date", "ofsted_framework", "ofsted_rc_date", diff --git a/backend/tests/test_compare_enrichment.py b/backend/tests/test_compare_enrichment.py index 0ff671f..9f8f09e 100644 --- a/backend/tests/test_compare_enrichment.py +++ b/backend/tests/test_compare_enrichment.py @@ -12,7 +12,8 @@ from fastapi.testclient import TestClient LATEST = 202425 CANNED_SUPPLEMENTARY = { - "ofsted": {"overall_effectiveness": 2, "grade_source": "graded", + "ofsted": {"overall_effectiveness": 2, + "current_grade": {"grade": 2, "date": "2023-01-01", "basis": "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}, @@ -86,7 +87,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"]["grade_source"] == "graded" + assert school["ofsted"]["current_grade"]["basis"] == "graded" assert school["census"]["fsm_pct"] == 29.8 assert school["admissions"]["second_preference_offers"] == 4 assert school["admissions_history"][0]["year"] == 202627 diff --git a/backend/tests/test_ofsted_status_payload.py b/backend/tests/test_ofsted_status_payload.py new file mode 100644 index 0000000..57e58c1 --- /dev/null +++ b/backend/tests/test_ofsted_status_payload.py @@ -0,0 +1,56 @@ +"""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"}) diff --git a/backend/tests/test_supplementary_batch.py b/backend/tests/test_supplementary_batch.py index 7813e7f..9d33c65 100644 --- a/backend/tests/test_supplementary_batch.py +++ b/backend/tests/test_supplementary_batch.py @@ -85,12 +85,15 @@ 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", )} - base.update(urn=urn, inspection_date=types.SimpleNamespace(isoformat=lambda: date), - overall_effectiveness=oe, grade_source=None) + 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") return types.SimpleNamespace(**base) @@ -114,10 +117,9 @@ def _dist_row(urn, year, distance_m, route_count=1): def test_one_query_per_table_and_latest_row_per_urn(): rows = { - # URN 1 has two Ofsted rows; the batch must keep the most recent (2023). - "FactOfstedInspection": [ + # The mart holds one current Ofsted status per URN. + "FactOfstedLatest": [ _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)], @@ -142,14 +144,14 @@ def test_one_query_per_table_and_latest_row_per_urn(): assert sorted(session.queries) == [ "FactAdmissionDistance", "FactAdmissions", "FactDeprivation", "FactFinance", "FactKs4Destinations", "FactKs5Destinations", - "FactOfstedInspection", "FactPupilCharacteristics", + "FactOfstedLatest", "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 - # Latest Ofsted kept per URN + # Each URN's current Ofsted status assert out[1]["ofsted"]["overall_effectiveness"] == 2 assert out[2]["ofsted"]["overall_effectiveness"] == 1 @@ -175,7 +177,7 @@ def test_one_query_per_table_and_latest_row_per_urn(): def test_single_wrapper_matches_batch(monkeypatch): - session = _FakeSession({"FactOfstedInspection": [_ofsted_row(5, "2022-01-01", 2)]}) + session = _FakeSession({"FactOfstedLatest": [_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"] == [] diff --git a/backend/tests/test_supplementary_enrichment.py b/backend/tests/test_supplementary_enrichment.py index 0fd5a38..b2212dd 100644 --- a/backend/tests/test_supplementary_enrichment.py +++ b/backend/tests/test_supplementary_enrichment.py @@ -10,7 +10,10 @@ from backend.data_loader import _admissions_row_dict, _ofsted_block def _row(**kw): base = dict( - framework="RC", inspection_date=None, inspection_type=None, + 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, overall_effectiveness=None, quality_of_education=None, behaviour_attitudes=None, personal_development=None, leadership_management=None, early_years_provision=None, @@ -33,20 +36,41 @@ def test_report_card_block_and_provider_url(): assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140" -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_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_ofsted_block_carries_rc_inspection_date(): o = _row( - ungraded_grade=2, rc_achievement=1, rc_inspection_date=date(2026, 2, 3), - inspection_date=date(2021, 10, 7), + graded_inspection_date=date(2021, 10, 7), ) block = _ofsted_block(o, urn=138690) assert block["rc_inspection_date"] == "2026-02-03" @@ -55,7 +79,7 @@ def test_ofsted_block_carries_rc_inspection_date(): def test_ofsted_block_rc_inspection_date_none_when_absent(): - o = _row(overall_effectiveness=1, inspection_date=date(2021, 10, 13)) + o = _row(overall_effectiveness=1, graded_inspection_date=date(2021, 10, 13)) block = _ofsted_block(o, urn=136276) assert block["rc_inspection_date"] is None @@ -63,6 +87,7 @@ 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 -- 2.54.0 From a42c586cc81bf01ad2b556c2d07355e21a55a1d4 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 5 Oct 2026 16:29:54 +0100 Subject: [PATCH 3/7] fix(ofsted): badge and compare read the grade still in force MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search badges date a grade by the inspection that awarded or confirmed it (ofsted_grade_date) and say 'Inspected · year' when the latest inspection gave no grade. ofstedDisplay's kinds become graded / confirmed / no_overall_grade, read from current_grade rather than a carried-forward overall_effectiveness. lib/ofstedStatus.ts holds the two sentences the school and compare pages share. Co-Authored-By: Claude Opus 5.5 --- nextjs-app/__tests__/lib/compareLogic.test.ts | 27 +++++------ nextjs-app/__tests__/lib/ofstedStatus.test.ts | 47 ++++++++++++++++++ nextjs-app/__tests__/lib/utils.test.ts | 26 +++++++--- nextjs-app/lib/compareLogic.ts | 29 ++++++----- nextjs-app/lib/ofstedStatus.ts | 48 +++++++++++++++++++ nextjs-app/lib/types.ts | 26 ++++++++-- nextjs-app/lib/utils.ts | 28 ++++++----- 7 files changed, 179 insertions(+), 52 deletions(-) create mode 100644 nextjs-app/__tests__/lib/ofstedStatus.test.ts create mode 100644 nextjs-app/lib/ofstedStatus.ts diff --git a/nextjs-app/__tests__/lib/compareLogic.test.ts b/nextjs-app/__tests__/lib/compareLogic.test.ts index 1437deb..ce217a4 100644 --- a/nextjs-app/__tests__/lib/compareLogic.test.ts +++ b/nextjs-app/__tests__/lib/compareLogic.test.ts @@ -114,16 +114,13 @@ describe('ofstedDisplay', () => { expect(d.kind).toBe('report_card'); }); - 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('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('handles missing data', () => { @@ -131,11 +128,11 @@ describe('ofstedDisplay', () => { expect(ofstedDisplay(ofsted({})).kind).toBe('none'); }); - 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('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('uses the four legacy grade words', () => { diff --git a/nextjs-app/__tests__/lib/ofstedStatus.test.ts b/nextjs-app/__tests__/lib/ofstedStatus.test.ts new file mode 100644 index 0000000..86deadc --- /dev/null +++ b/nextjs-app/__tests__/lib/ofstedStatus.test.ts @@ -0,0 +1,47 @@ +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); + }); +}); diff --git a/nextjs-app/__tests__/lib/utils.test.ts b/nextjs-app/__tests__/lib/utils.test.ts index 9fb5042..ab03561 100644 --- a/nextjs-app/__tests__/lib/utils.test.ts +++ b/nextjs-app/__tests__/lib/utils.test.ts @@ -147,23 +147,37 @@ describe('ofstedLegacyAreas', () => { describe('buildOfstedListBadge', () => { it('returns grade word + year for OEIF Outstanding', () => { - const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' }); + const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_grade_date: '2023-11-15', 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_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'); + 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'); }); - it('returns grade word without year when date is missing', () => { - const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: null }); + 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' }); 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'); diff --git a/nextjs-app/lib/compareLogic.ts b/nextjs-app/lib/compareLogic.ts index a10e1a7..9b7b9a9 100644 --- a/nextjs-app/lib/compareLogic.ts +++ b/nextjs-app/lib/compareLogic.ts @@ -100,9 +100,8 @@ export function summariseReportCard(ofsted: OfstedInspection): ReportCardSummary export type OfstedDisplay = | { kind: 'none' } - | { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false } - | { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true } - | { kind: 'transitional' } + | { kind: 'graded' | 'confirmed'; grade: number; gradeLabel: string; gradeDate: string | null } + | { kind: 'no_overall_grade' } | { kind: 'report_card'; summary: ReportCardSummary }; export function ofstedDisplay( @@ -116,19 +115,19 @@ export function ofstedDisplay( return { kind: 'report_card', summary: summariseReportCard(ofsted) }; } - 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' }; + // 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, + }; } - - if (ofsted.grade_source === 'ungraded_carried_forward') { - return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true }; - } - return { kind: 'graded', grade, gradeLabel, carriedForward: false }; + return ofsted.latest_visit ? { kind: 'no_overall_grade' } : { kind: 'none' }; } // --------------------------------------------------------------------------- diff --git a/nextjs-app/lib/ofstedStatus.ts b/nextjs-app/lib/ofstedStatus.ts new file mode 100644 index 0000000..f9164a5 --- /dev/null +++ b/nextjs-app/lib/ofstedStatus.ts @@ -0,0 +1,48 @@ +/** + * 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 = { + 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, +): 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'; +} diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 11c5582..39ab734 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -76,10 +76,14 @@ 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; } @@ -95,6 +99,7 @@ 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; @@ -115,9 +120,12 @@ export interface OfstedInspection { rc_leadership_governance: number | null; rc_early_years: number | null; rc_sixth_form: number | 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; + /** 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; /** 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. */ @@ -127,6 +135,18 @@ 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; diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 8fbea04..e14492f 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -837,15 +837,18 @@ 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". - * - 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 + * - 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 * "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; @@ -858,10 +861,7 @@ export function buildOfstedListBadge(school: { return { label: `Report Card · ${rcYear}`, cssClass: 'ofstedRc' }; } - const year = school.ofsted_date - ? new Date(school.ofsted_date).getFullYear() - : null; - const yearStr = year ? ` · ${year}` : ''; + const yearOf = (iso?: string | null) => (iso ? new Date(iso).getFullYear() : null); if (school.ofsted_grade) { const labels: Record = { @@ -870,17 +870,19 @@ export function buildOfstedListBadge(school: { 3: 'Req. Improvement', 4: 'Inadequate', }; + const gradeYear = yearOf(school.ofsted_grade_date); return { - label: `${labels[school.ofsted_grade]}${yearStr}`, + label: `${labels[school.ofsted_grade]}${gradeYear ? ` · ${gradeYear}` : ''}`, cssClass: `ofsted${school.ofsted_grade}`, }; } - // 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. + // 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. if (school.ofsted_date != null || school.ofsted_framework != null) { - return { label: `Inspected${yearStr}`, cssClass: 'ofstedInspected' }; + const visitYear = yearOf(school.ofsted_date); + return { label: `Inspected${visitYear ? ` · ${visitYear}` : ''}`, cssClass: 'ofstedInspected' }; } return { label: 'Not yet inspected', cssClass: 'ofstedPending' }; -- 2.54.0 From 9ef48da4b10bf2a18eb7b1eeb4690fb74d0614ff Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 5 Oct 2026 16:31:26 +0100 Subject: [PATCH 4/7] fix(school): the Ofsted section shows the grade still in force and the latest visit The section is dated by the latest visit and headlines the grade still in force with the inspection that awarded or confirmed it, or 'No overall grade'. A later visit that isn't the grade's source gets its own line, with an ungraded outcome such as 'Standards maintained'. The primary and secondary no-grade branches merge, so the secondary page no longer drops the sixth-form and early-years judgements (audit M2), and 'Not rated' goes. Co-Authored-By: Claude Opus 5.5 --- .../components/OfstedSection.test.tsx | 84 +++++++++++++ .../__tests__/support/schoolFixtures.ts | 6 +- .../components/school/OfstedSection.tsx | 113 ++++++++---------- .../school/PrimarySchoolSections.tsx | 9 +- .../school/SecondarySchoolSections.tsx | 4 +- 5 files changed, 147 insertions(+), 69 deletions(-) create mode 100644 nextjs-app/__tests__/components/OfstedSection.test.tsx diff --git a/nextjs-app/__tests__/components/OfstedSection.test.tsx b/nextjs-app/__tests__/components/OfstedSection.test.tsx new file mode 100644 index 0000000..3f16072 --- /dev/null +++ b/nextjs-app/__tests__/components/OfstedSection.test.tsx @@ -0,0 +1,84 @@ +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, page: Record = {}) { + const ofsted = { ...empty, ...o } as OfstedInspection; + render( + , + ); +} + +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(); +}); diff --git a/nextjs-app/__tests__/support/schoolFixtures.ts b/nextjs-app/__tests__/support/schoolFixtures.ts index 7eb4c55..332a3cf 100644 --- a/nextjs-app/__tests__/support/schoolFixtures.ts +++ b/nextjs-app/__tests__/support/schoolFixtures.ts @@ -191,7 +191,8 @@ export const primaryFixture = { personal_development: 2, leadership_management: 2, previous_overall: 3, - grade_source: 'graded', + current_grade: { grade: 2, date: '2023-05-17', basis: 'graded' }, + latest_visit: { date: '2023-05-17', kind: 'graded', outcome: null }, }), census, admissions: makeAdmissions({ year: 2024 }), @@ -295,7 +296,8 @@ export const allThroughFixture = { behaviour_attitudes: 1, personal_development: 1, leadership_management: 1, - grade_source: 'graded', + current_grade: { grade: 1, date: '2022-10-04', basis: 'graded' }, + latest_visit: { date: '2022-10-04', kind: 'graded', outcome: null }, }), census, admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }), diff --git a/nextjs-app/components/school/OfstedSection.tsx b/nextjs-app/components/school/OfstedSection.tsx index 7d2d202..e393ca3 100644 --- a/nextjs-app/components/school/OfstedSection.tsx +++ b/nextjs-app/components/school/OfstedSection.tsx @@ -1,16 +1,18 @@ /** * OfstedSection — shared between the primary and secondary detail pages. * - * 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. + * 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 * * 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'; @@ -40,14 +42,13 @@ 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, variant = 'primary', + oeifAllSameGrade, oeifAreas, }: OfstedSectionProps) { - const isSecondary = variant === 'secondary'; + const display = ofstedDisplay(ofsted); return (
@@ -55,7 +56,7 @@ export function OfstedSection({ {isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'} {ofstedInspectedDate && ( - {isSecondary && ' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} + Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} )} - ) : (!isSecondary || ofsted.overall_effectiveness) ? ( - /* ── Old OEIF layout ── */ + ) : ( <>
- - {ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'} - - {ofsted.previous_overall != null && - ofsted.previous_overall !== ofsted.overall_effectiveness && ( - - Previously: {OFSTED_LABELS[ofsted.previous_overall]} - + {display.kind === 'graded' || display.kind === 'confirmed' ? ( + <> + + {display.gradeLabel} + + {ofsted.previous_overall != null && ofsted.previous_overall !== display.grade && ( + + Previously: {OFSTED_LABELS[ofsted.previous_overall]} + + )} + + ) : ( + No overall grade )}

- {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.'} + {ofsted.current_grade + ? gradeSourceLine(ofsted.current_grade) + : 'Ofsted stopped giving overall grades in September 2024.'}

- {oeifAllSameGrade ? ( -

- Rated {OFSTED_LABELS[ofsted.overall_effectiveness!]} across all inspected areas: Quality of Teaching, Behaviour, Pupils' Development and Leadership. -

- ) : ( -
- {oeifAreas.map(({ label, value }) => ( -
-
{label}
-
- {OFSTED_LABELS[value]} -
-
- ))} -
+ {showLatestVisitLine(ofsted) && ofsted.latest_visit && ( +

Latest visit: {latestVisitLine(ofsted.latest_visit)}

)} - - ) : ( - /* ── Secondary only: inspected since Sept 2024, no overall grade ── */ - <> -

- From September 2024, Ofsted no longer gives a single overall grade. -

-
- {[ - { 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 }) => ( -
-
{label}
-
- {OFSTED_LABELS[value!]} -
+ {oeifAllSameGrade && (display.kind === 'graded' || display.kind === 'confirmed') ? ( +

+ Rated {display.gradeLabel} across all inspected areas: Quality of Teaching, Behaviour, Pupils' Development and Leadership. +

+ ) : oeifAreas.length > 0 ? ( + <> + {ofsted.inspection_date && ofsted.inspection_date !== ofsted.latest_visit?.date && ( +

+ Area judgements from the graded inspection, {formatOfstedDate(ofsted.inspection_date)}. +

+ )} +
+ {oeifAreas.map(({ label, value }) => ( +
+
{label}
+
+ {OFSTED_LABELS[value]} +
+
+ ))}
- ))} -
+ + ) : null} )}
diff --git a/nextjs-app/components/school/PrimarySchoolSections.tsx b/nextjs-app/components/school/PrimarySchoolSections.tsx index 319ee86..623027b 100644 --- a/nextjs-app/components/school/PrimarySchoolSections.tsx +++ b/nextjs-app/components/school/PrimarySchoolSections.tsx @@ -55,15 +55,17 @@ 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), never - // the legacy inspection_date (report cards exist only from Nov 2025). + // 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. const ofstedInspectedDate = isReportCard ? ofsted?.rc_inspection_date ?? null - : ofsted?.inspection_date ?? null; + : ofsted?.latest_visit?.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); @@ -77,7 +79,6 @@ export function PrimarySchoolSections({ ofstedInspectedDate={ofstedInspectedDate} oeifAllSameGrade={oeifAllSameGrade} oeifAreas={oeifAreas} - variant="primary" /> )} diff --git a/nextjs-app/components/school/SecondarySchoolSections.tsx b/nextjs-app/components/school/SecondarySchoolSections.tsx index 7fcbe98..9526e8d 100644 --- a/nextjs-app/components/school/SecondarySchoolSections.tsx +++ b/nextjs-app/components/school/SecondarySchoolSections.tsx @@ -63,11 +63,12 @@ export function SecondarySchoolSections({ const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0); const ofstedInspectedDate = isReportCard ? ofsted?.rc_inspection_date ?? null - : ofsted?.inspection_date ?? null; + : ofsted?.latest_visit?.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); @@ -81,7 +82,6 @@ export function SecondarySchoolSections({ ofstedInspectedDate={ofstedInspectedDate} oeifAllSameGrade={oeifAllSameGrade} oeifAreas={oeifAreas} - variant="secondary" /> )} -- 2.54.0 From d37baa572b559e906f07aa8eb2723d00a646dcdb Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 5 Oct 2026 16:32:19 +0100 Subject: [PATCH 5/7] fix(compare): Ofsted rows show the grade still in force and the latest visit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Inspected' is the school's latest visit, so Washwood Heath reads 21 May 2025 rather than '3 Mar 2020 · 4+ years ago' (audit M1). The Result row names the inspection a grade came from, or says 'No overall grade'; 'Grade carried forward' and 'transitional framework' go. Co-Authored-By: Claude Opus 5.5 --- .../components/CompareOfsted.test.tsx | 42 ++++++++++++++++--- .../components/compare/CompareAtAGlance.tsx | 15 +++---- .../components/compare/CompareOfsted.tsx | 36 +++++++--------- 3 files changed, 56 insertions(+), 37 deletions(-) diff --git a/nextjs-app/__tests__/components/CompareOfsted.test.tsx b/nextjs-app/__tests__/components/CompareOfsted.test.tsx index 78c0bdf..039e22a 100644 --- a/nextjs-app/__tests__/components/CompareOfsted.test.tsx +++ b/nextjs-app/__tests__/components/CompareOfsted.test.tsx @@ -33,18 +33,26 @@ function ofsted(partial: Partial): OfstedInspection { }; } -const schools = [school(1, 'Graded School'), school(2, 'Carried School'), school(3, 'Card School')]; +const schools = [school(1, 'Graded School'), school(2, 'Confirmed School'), school(3, 'Card School')]; const data: Record = { '1': { school_info: schools[0], yearly_data: [], - ofsted: ofsted({ overall_effectiveness: 1, grade_source: 'graded' }), + 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 }, + }), }, '2': { school_info: schools[1], yearly_data: [], - ofsted: ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }), + 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' }, + }), }, '3': { school_info: schools[2], @@ -65,9 +73,9 @@ describe('CompareOfsted', () => { render(); expect(screen.getByText('Outstanding')).toBeInTheDocument(); - // Carried-forward grade is shown but marked as such + // A grade confirmed at an ungraded visit says so, with that visit's date expect(screen.getByText('Good')).toBeInTheDocument(); - expect(screen.getByText(/carried forward/i)).toBeInTheDocument(); + expect(screen.getByText('Confirmed at an ungraded inspection, 14 March 2023')).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(); @@ -104,7 +112,7 @@ describe('CompareOfsted', () => { yearly_data: [], ofsted: ofsted({ overall_effectiveness: 2, - grade_source: 'graded', + current_grade: { grade: 2, date: '2021-10-07', basis: 'graded' }, quality_of_education: 1, early_years_provision: 9, sixth_form_provision: 2, @@ -167,4 +175,26 @@ 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(); + 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(); + expect(screen.getAllByText('No overall grade').length).toBeGreaterThan(0); + expect(screen.queryByText('Good')).not.toBeInTheDocument(); + }); }); diff --git a/nextjs-app/components/compare/CompareAtAGlance.tsx b/nextjs-app/components/compare/CompareAtAGlance.tsx index 0cafadc..446742e 100644 --- a/nextjs-app/components/compare/CompareAtAGlance.tsx +++ b/nextjs-app/components/compare/CompareAtAGlance.tsx @@ -78,21 +78,18 @@ export function CompareAtAGlance({ return ( {display.kind === 'report_card' && } - {(display.kind === 'graded' || display.kind === 'carried_forward') && ( + {(display.kind === 'graded' || display.kind === 'confirmed') && ( <> {display.gradeLabel} - {display.carriedForward && Grade carried forward} + {display.kind === 'confirmed' && Confirmed at an ungraded inspection} )} - {display.kind === 'transitional' && ( - <> - - No overall grade - - Sub-judgements only - + {display.kind === 'no_overall_grade' && ( + + No overall grade + )} {display.kind === 'none' && No inspection in our dataset} diff --git a/nextjs-app/components/compare/CompareOfsted.tsx b/nextjs-app/components/compare/CompareOfsted.tsx index ad61ac4..16135b4 100644 --- a/nextjs-app/components/compare/CompareOfsted.tsx +++ b/nextjs-app/components/compare/CompareOfsted.tsx @@ -1,7 +1,8 @@ /** * Ofsted section — one visual grammar for inspection detail across all - * three regimes (legacy graded, interim carried-forward, renewed-framework - * report card). Copy comes verbatim from the reviewed mockups. + * 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. */ 'use client'; @@ -12,7 +13,8 @@ import { rcAreaLabel, type OfstedDisplay, } from '@/lib/compareLogic'; -import type { ComparisonData, OfstedInspection, School } from '@/lib/types'; +import { gradeSourceLine } from '@/lib/ofstedStatus'; +import type { ComparisonData, OfstedCurrentGrade, OfstedInspection, School } from '@/lib/types'; import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared'; const GRADE_TONE: Record = { @@ -39,7 +41,7 @@ function yearsSince(iso: string | null): number | null { return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000); } -function ResultCell({ display }: { display: OfstedDisplay }) { +function ResultCell({ display, current }: { display: OfstedDisplay; current: OfstedCurrentGrade | null }) { if (display.kind === 'none') { return No inspection outcome in our dataset; } @@ -51,15 +53,13 @@ function ResultCell({ display }: { display: OfstedDisplay }) { ); } - if (display.kind === 'transitional') { + if (display.kind === 'no_overall_grade') { return ( <> No overall grade - - Inspected under transitional framework (sub-judgements only) - + Ofsted stopped giving overall grades in September 2024 ); } @@ -68,11 +68,7 @@ function ResultCell({ display }: { display: OfstedDisplay }) { {display.gradeLabel} - - {display.carriedForward - ? 'Grade carried forward from an earlier inspection (ungraded visit since)' - : 'Overall grade (older-style inspection)'} - + {current && {gradeSourceLine(current)}} ); } @@ -179,7 +175,7 @@ export function CompareOfsted({ {schools.map((school, i) => ( - + ))} @@ -187,19 +183,15 @@ export function CompareOfsted({ {schools.map((school, i) => { const ofsted = data[String(school.urn)]?.ofsted; - // 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; + // 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; const age = yearsSince(dateIso); return ( {formatInspectionDate(dateIso)}{' '} {age != null && age > 4 && 4+ years ago} + {ofsted?.latest_visit?.outcome && {ofsted.latest_visit.outcome}} ); })} -- 2.54.0 From 5eed09dfa7d95d7c5baa88215926d4ce8c49ea11 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 5 Oct 2026 16:32:53 +0100 Subject: [PATCH 6/7] refactor(utils): drop the unused Ofsted hero chip and summary sentence buildOfstedHeroChip and buildSchoolSummary were rendered nowhere and encoded the old carried-forward rule (and a framework value, 'ReportCard', the API never sends). Co-Authored-By: Claude Opus 5.5 --- nextjs-app/lib/utils.ts | 168 +--------------------------------------- 1 file changed, 1 insertion(+), 167 deletions(-) diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index e14492f..75c873a 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -2,7 +2,7 @@ * Utility functions for SchoolCompare */ -import type { School, MetricDefinition, OfstedInspection, SchoolAdmissions, SchoolResult } from './types'; +import type { School, MetricDefinition } from './types'; // ============================================================================ // String Utilities @@ -619,172 +619,6 @@ export function getCurrentAcademicYear(): number { return month >= 8 ? year : year - 1; } -// ============================================================================ -// School Detail Hero Helpers -// ============================================================================ - -const OFSTED_OEIF_WORDS: Record = { - 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 { -- 2.54.0 From f86d6f45d17704a5c8cd4437775d0d8df659b193 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 5 Oct 2026 16:33:53 +0100 Subject: [PATCH 7/7] test(e2e): no grade is carried past a no-grade inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rabbsfarm (102408) must read 'Inspected · 2025' with no overall grade, and Robins Lane (104762) must say its Good was confirmed at an ungraded inspection on 18 July 2024. Both fail against staging today (no current_grade field, no confirmation line) and pass once PR #183's pipeline run and this branch are deployed. Co-Authored-By: Claude Opus 5.5 --- e2e/tests/journeys.spec.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 86bd04d..8ffe56b 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -474,6 +474,37 @@ 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(); -- 2.54.0