Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 5.5 0269eec9c4 test(e2e): never pick an ISI school for the not-yet-inspected journey (review)
PR Checks / Frontend Typecheck + Tests (pull_request) Waiting to run
PR Checks / Backend Smoke (pull_request) Waiting to run
PR Checks / Build Backend (no push) (pull_request) Waiting to run
PR Checks / Build Frontend (no push) (pull_request) Waiting to run
PR Checks / Build Pipeline (no push) (pull_request) Waiting to run
PR Checks / AI Code Review (Claude) (pull_request) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:55:32 +01:00
TudorandClaude Opus 5.5 81de008e4a test(e2e): further education, ISI and not-yet-inspected states (H4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:35:53 +01:00
TudorandClaude Opus 5.5 9feb37b974 fix(school, compare): ISI, not-yet-inspected and further-education states (H4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:35:10 +01:00
TudorandClaude Opus 5.5 ab25d83b9d fix(search): no "Not yet inspected" without Ofsted's word; ISI badge (H4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:33:14 +01:00
TudorandClaude Opus 5.5 69da9d84db fix(api): inspectorate and Ofsted listing in payloads; Ofsted links that resolve (H4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:32:06 +01:00
tudor 2b5e681482 Merge pull request 'fix(site): DfE LA averages for "vs LA avg"; no gap for independents (H2, part 2 of 2)' (#186) from fix/la-averages-site into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m38s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 26s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m30s
Reviewed-on: #186
2026-10-06 15:07:36 +00:00
tudor 3bd1dbd276 Merge pull request 'fix(pipeline): load 2023/24 KS4/KS2 data and DfE LA averages (C2, H2, part 1 of 2)' (#185) from fix/ks4-2023-24-pipeline into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 0s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 45s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m33s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m6s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 6s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m27s
Reviewed-on: #185
2026-10-06 12:42:19 +00:00
TudorandClaude Opus 5.5 870af949ee test(e2e): prove the independent row against a state row's gap (review)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m26s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
The journey passed whenever the LA map was empty or not yet rendered. It now
waits for a state school in the same LA to show its gap, and both LA journeys
fail rather than skip on an empty map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:51:18 +01:00
TudorandClaude Opus 5.5 c6ff77f05b test(e2e): 2023/24 results and no LA gap for independents (C2, H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:37:02 +01:00
TudorandClaude Opus 5.5 c7ddf0505d fix(search): no LA comparison for independent schools (H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:36:00 +01:00
TudorandClaude Opus 5.5 e659867590 fix(api): serve DfE's LA averages for "vs LA avg" (H2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:35:11 +01:00
32 changed files with 689 additions and 75 deletions

No files matched your search

+62 -8
View File
@@ -1048,6 +1048,11 @@ async def get_school_details(request: Request, urn: int):
"age_range": latest.get("age_range", ""),
"has_sixth_form": latest.get("has_sixth_form"),
"nursery_provision": latest.get("nursery_provision"),
# What the Ofsted badge may say when there is no inspection record
# (audit H4): the inspectorate GIAS names, and whether Ofsted's own
# data lists the school at all.
"inspectorate": latest.get("inspectorate"),
"ofsted_listed": latest.get("ofsted_listed"),
# The header's Selective flag reads it (lib/schoolFacts).
"admissions_policy": latest.get("admissions_policy"),
"status": latest.get("status"),
@@ -1210,6 +1215,9 @@ async def compare_schools(
# schools get entrance-test framing, never the distance template.
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
# Audit H4: what compare may say when there is no inspection record.
"inspectorate": convert_to_native(latest.get("inspectorate")),
"ofsted_listed": convert_to_native(latest.get("ofsted_listed")),
"capacity": convert_to_native(latest.get("capacity")),
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
"trust_name": convert_to_native(latest.get("trust_name")),
@@ -1273,17 +1281,63 @@ async def get_filter_options(request: Request):
}
def _la_averages_payload(df: pd.DataFrame) -> dict:
"""Per-LA Attainment 8 for the "vs LA avg" comparison: DfE's own LA
averages (fact_ks4_la_averages, all state-funded schools), never a mean of
the dataframe. That mean counted independent and special schools and put
most LAs about 7 points low (audit H2).
The year is the latest with any school Attainment 8, so the average and
the scores set against it are the same year. Figures are keyed by our LA
name through the LA code. An LA without a DfE figure for that year (City of
London) is absent; no figures for the year, or no mart, give an empty map,
so rows show no comparison, never another year's figure.
"""
empty = {"year": 0, "secondary": {"attainment_8_by_la": {}}}
if df.empty or "attainment_8_score" not in df.columns:
return empty
scored = df[df["attainment_8_score"].notna()]
if scored.empty:
return empty
year = int(scored["year"].max())
la = (df[["local_authority_code", "local_authority"]]
.dropna()
.drop_duplicates("local_authority_code"))
name_by_code = {int(code): name for code, name in
zip(la["local_authority_code"], la["local_authority"])}
from . import database
from .models import Ks4LaAverage
rows: list = []
db = None
try:
db = database.SessionLocal()
rows = db.query(Ks4LaAverage).filter(Ks4LaAverage.year == year).all()
except Exception:
import logging
logging.getLogger(__name__).warning(
"DfE LA averages unavailable for %s", year, exc_info=True)
if db is not None:
db.rollback()
finally:
if db is not None:
db.close()
by_la = {
name_by_code[row.la_code]: row.attainment_8_score
for row in rows
if row.attainment_8_score is not None and row.la_code in name_by_code
}
return {"year": year, "secondary": {"attainment_8_by_la": by_la}}
@app.get("/api/la-averages")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_la_averages(request: Request):
"""Get per-LA average Attainment 8 score for secondary schools in the latest year."""
df = load_school_data()
if df.empty:
return {"year": 0, "secondary": {"attainment_8_by_la": {}}}
latest_year = int(df["year"].max())
sec_df = df[(df["year"] == latest_year) & df["attainment_8_score"].notna()]
la_avg = sec_df.groupby("local_authority")["attainment_8_score"].mean().round(1).to_dict()
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
"""DfE's per-LA Attainment 8 averages for the latest year with results."""
return _la_averages_payload(load_school_data())
_KS2_NATIONAL_METRICS = [
+8 -1
View File
@@ -251,6 +251,8 @@ _MAIN_QUERY = text("""
s.website,
s.telephone,
s.nursery_provision,
s.inspectorate,
s.ofsted_listed,
foi.current_grade AS ofsted_grade,
foi.current_grade_date AS ofsted_grade_date,
foi.latest_visit_date AS ofsted_date,
@@ -376,7 +378,8 @@ for _old, _new in _LEGACY_NAME_REPLACEMENTS:
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
# Fallback used when marts predate the optional GIAS/location detail columns
# (telephone, nursery provision, county, parliamentary constituency) — i.e. the
# (telephone, nursery provision, county, parliamentary constituency, and the
# inspectorate / Ofsted-listing columns added for audit H4) — i.e. the
# nightly dbt pipeline hasn't rebuilt the mart yet on this DB. Keeps each column
# present as NULL so the whole data load degrades gracefully instead of failing.
_EXTRA_OPTIONAL_COLUMNS = (
@@ -384,6 +387,8 @@ _EXTRA_OPTIONAL_COLUMNS = (
"nursery_provision",
"county",
"parliamentary_constituency",
"inspectorate",
"ofsted_listed",
)
_MAIN_QUERY_NO_EXTRA_COLS = str(_MAIN_QUERY)
for _col, _tbl in (
@@ -391,6 +396,8 @@ for _col, _tbl in (
("nursery_provision", "s"),
("county", "l"),
("parliamentary_constituency", "l"),
("inspectorate", "s"),
("ofsted_listed", "s"),
):
_src = f"{_tbl}.{_col},"
assert _src in _MAIN_QUERY_NO_EXTRA_COLS, (
+12
View File
@@ -344,6 +344,18 @@ class Ks4NationalAverage(Base):
gcse_grade_91_pct = Column(Float)
class Ks4LaAverage(Base):
"""Official DfE KS4 local-authority averages (all state-funded schools) —
one row per academic year and LA. la_code is the GIAS LA code."""
__tablename__ = "fact_ks4_la_averages"
__table_args__ = MARTS
year = Column(Integer, primary_key=True)
la_code = Column(Integer, primary_key=True)
la_name = Column(String)
attainment_8_score = Column(Float)
class Ks2NationalAverage(Base):
"""Official DfE KS2 national headline averages — one row per academic year."""
__tablename__ = "fact_ks2_national_averages"
+6 -3
View File
@@ -39,6 +39,9 @@ def report_card_labels(ofsted: dict) -> dict:
def ofsted_page_url(urn: int) -> str:
"""The school's page on ofsted.gov.uk (all its reports live there —
we never deep-link an individual report)."""
return f"https://reports.ofsted.gov.uk/provider/21/{urn}"
"""The school's page on ofsted.gov.uk (all its reports live there — we
never deep-link an individual report). Ofsted's provider pages sit under a
category (21 primary, 23 secondary, 25 special, 40 FE colleges, …); this
address redirects to the right one, where provider/21 404s for every
non-primary school."""
return f"https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/{urn}"
+2
View File
@@ -578,6 +578,8 @@ SCHOOL_COLUMNS = [
"ofsted_date",
"ofsted_framework",
"ofsted_rc_date",
"inspectorate",
"ofsted_listed",
"latitude",
"longitude",
]
@@ -0,0 +1,63 @@
"""Audit H4: every payload that feeds an Ofsted badge carries the inspectorate
GIAS names and whether Ofsted's own data lists the school, so the site never
says "Not yet inspected" on the strength of missing data."""
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _df():
base = dict(year=202425, local_authority='Cambridgeshire', local_authority_code=873,
postcode='CB1 1AA', latitude=52.2, longitude=0.12, address='1 High St',
status='Open', town='Cambridge')
return pd.DataFrame([
dict(base, urn=136083, school_name='Abbey College Cambridge', phase='Secondary',
school_type='Other independent school', inspectorate='ISI', ofsted_listed=False),
dict(base, urn=150031, school_name='Abbas and Templecombe Church School', phase='Primary',
school_type='Academy converter', inspectorate='Not applicable', ofsted_listed=True),
])
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
from backend import database as database_module
def _no_db():
raise RuntimeError('no database in this test')
monkeypatch.setattr(app_module, 'load_latest_school_data', _df)
monkeypatch.setattr(app_module, 'load_school_data', _df)
monkeypatch.setattr(app_module, 'get_supplementary_data', lambda db, urn: {})
monkeypatch.setattr(app_module, '_place_registry', None)
monkeypatch.setattr(database_module, 'SessionLocal', _no_db)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_the_list_carries_inspectorate_and_listing(client):
resp = client.get('/api/schools?page_size=50')
assert resp.status_code == 200, resp.text
by_urn = {s['urn']: s for s in resp.json()['schools']}
assert by_urn[136083]['inspectorate'] == 'ISI'
assert by_urn[150031]['ofsted_listed'] is True
def test_the_school_page_carries_them(client):
info = client.get('/api/schools/136083').json()['school_info']
assert info['inspectorate'] == 'ISI'
assert info['ofsted_listed'] is False
def test_compare_carries_them(client):
body = client.get('/api/compare?urns=136083,150031').json()
assert body['comparison']['150031']['school_info']['ofsted_listed'] is True
assert body['comparison']['136083']['school_info']['inspectorate'] == 'ISI'
def test_a_mart_built_before_the_columns_still_loads():
# Missing columns make the main query retry with NULLs (data_loader).
from backend import data_loader
assert {'inspectorate', 'ofsted_listed'} <= set(data_loader._EXTRA_OPTIONAL_COLUMNS)
fallback = str(data_loader._MAIN_QUERY_NO_EXTRA_COLS)
assert 'NULL AS inspectorate,' in fallback and 'NULL AS ofsted_listed,' in fallback
+119
View File
@@ -0,0 +1,119 @@
"""/api/la-averages serves DfE's own LA averages (fact_ks4_la_averages).
It used to average the dataframe: every school with an Attainment 8,
independent and special schools included. Kensington and Chelsea came out at
35.2 against DfE's 54.5, and most LAs about 7 points low (audit H2).
"""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
LATEST = 202425
def _df():
return pd.DataFrame([
# A state school and an independent: their mean, 37.65, is not DfE's figure.
dict(year=LATEST, local_authority="Kensington and Chelsea", local_authority_code=207, attainment_8_score=54.9),
dict(year=LATEST, local_authority="Kensington and Chelsea", local_authority_code=207, attainment_8_score=20.4),
dict(year=LATEST, local_authority="Bristol, City of", local_authority_code=801, attainment_8_score=45.0),
dict(year=LATEST, local_authority="West Sussex", local_authority_code=938, attainment_8_score=48.0),
# DfE publishes no LA figure for City of London.
dict(year=LATEST, local_authority="City of London", local_authority_code=201, attainment_8_score=30.0),
# A newer year with primary results only.
dict(year=202526, local_authority="Kensington and Chelsea", local_authority_code=207, attainment_8_score=np.nan),
])
class _Row:
def __init__(self, year, la_code, la_name, attainment_8_score):
self.year = year
self.la_code = la_code
self.la_name = la_name
self.attainment_8_score = attainment_8_score
class _StubSession:
rows = [
_Row(LATEST, 207, "Kensington and Chelsea", 54.5),
_Row(LATEST, 801, "Bristol City", 46.3), # DfE's spelling, not ours
_Row(LATEST, 938, "West Sussex", None), # suppressed
_Row(LATEST, 330, "Birmingham", 44.0), # no school of ours there
_Row(202324, 207, "Kensington and Chelsea", 54.5),
]
def query(self, model):
assert model.__name__ == "Ks4LaAverage"
return self
def filter(self, condition):
# The payload filters on year == <year>; apply it as Postgres would.
self._year = condition.right.value
return self
def all(self):
return [r for r in self.rows if r.year == self._year]
def rollback(self):
pass
def close(self):
pass
class _OldYearOnly(_StubSession):
rows = [_Row(202324, 207, "Kensington and Chelsea", 54.5)]
class _NoMart(_StubSession):
def all(self):
raise RuntimeError('relation "marts.fact_ks4_la_averages" does not exist')
@pytest.fixture()
def payload(monkeypatch):
from backend import app as app_module
from backend import database as database_module
def _run(session_cls):
monkeypatch.setattr(database_module, "SessionLocal", session_cls)
return app_module._la_averages_payload(_df())
return _run
def test_serves_dfe_figures_keyed_by_our_la_names(payload):
out = payload(_StubSession)
assert out["year"] == LATEST
assert out["secondary"]["attainment_8_by_la"] == {
"Kensington and Chelsea": 54.5,
"Bristol, City of": 46.3,
}
def test_an_la_without_a_dfe_figure_is_absent(payload):
by_la = payload(_StubSession)["secondary"]["attainment_8_by_la"]
assert "City of London" not in by_la
assert "West Sussex" not in by_la
assert None not in by_la.values()
def test_no_dfe_figures_for_the_year_give_an_empty_map(payload):
assert payload(_OldYearOnly) == {"year": LATEST, "secondary": {"attainment_8_by_la": {}}}
def test_a_missing_mart_gives_an_empty_map(payload):
assert payload(_NoMart)["secondary"]["attainment_8_by_la"] == {}
def test_the_endpoint_serves_dfe_figures(monkeypatch):
from backend import app as app_module
from backend import database as database_module
monkeypatch.setattr(app_module, "load_school_data", _df)
monkeypatch.setattr(database_module, "SessionLocal", _StubSession)
resp = TestClient(app_module.app).get("/api/la-averages")
assert resp.status_code == 200, resp.text
assert resp.json()["secondary"]["attainment_8_by_la"]["Kensington and Chelsea"] == 54.5
+1 -1
View File
@@ -42,4 +42,4 @@ def test_unknown_code_is_skipped_not_crashed():
def test_provider_url():
assert ofsted_page_url(138690) == "https://reports.ofsted.gov.uk/provider/21/138690"
assert ofsted_page_url(138690) == "https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/138690"
@@ -33,7 +33,7 @@ def test_report_card_block_and_provider_url():
assert block["report_card"]["rc_achievement"]["label"] == "Strong standard"
assert "rc_safeguarding_met" not in block["report_card"]
assert block["rc_safeguarding_met"] is True
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140"
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/100140"
def test_no_grade_is_carried_past_a_newer_inspection():
+93 -1
View File
@@ -417,12 +417,15 @@ test('a secondary search row compares its Attainment 8 with the LA average', asy
// guards the comparison itself; the unit test pins the cache mode.
const la = await (await page.request.get('/api/la-averages')).json();
const averages: Record<string, number> = la.secondary?.attainment_8_by_la ?? {};
// DfE publishes about 152 LA averages. An empty map (a missing mart, or a
// year the LA data set has not reached) hides every comparison: fail, not skip.
expect(Object.keys(averages).length).toBeGreaterThan(100);
const res = await page.request.get('/api/schools?search=school&phase=secondary&page_size=50');
expect(res.ok()).toBeTruthy();
const school = ((await res.json()).schools ?? []).find(
(s: { attainment_8_score?: number | null; local_authority?: string; school_type?: string }) =>
s.attainment_8_score != null && s.local_authority != null && averages[s.local_authority] != null
&& !/special|pupil referral|alternative provision/i.test(s.school_type ?? ''));
&& !/special|pupil referral|alternative provision|independent/i.test(s.school_type ?? ''));
test.skip(!school, 'no mainstream secondary with an LA average here');
await searchByName(page, school.school_name);
@@ -433,6 +436,52 @@ test('a secondary search row compares its Attainment 8 with the LA average', asy
await expect(stats.getByText(/vs LA avg/)).toBeVisible();
});
test('an independent secondary shows its Attainment 8 without an LA comparison', async ({ page }) => {
// DfE's LA averages cover state-funded schools, and an independent school's
// Attainment 8 leaves out IGCSEs, so a gap would mislead (audit H2). A state
// school on the same page must show its gap first, so the absence is real.
const la = await (await page.request.get('/api/la-averages')).json();
const averages: Record<string, number> = la.secondary?.attainment_8_by_la ?? {};
expect(Object.keys(averages).length).toBeGreaterThan(100);
type Row = { urn: number; school_type?: string; local_authority?: string; attainment_8_score?: number | null };
const compared = (s: Row) => s.attainment_8_score != null && s.local_authority != null
&& averages[s.local_authority] != null
&& !/special|pupil referral|alternative provision/i.test(s.school_type ?? '');
let found: { la: string; state: Row; independent: Row } | null = null;
for (const name of ['Kensington and Chelsea', 'Westminster', 'Camden', 'Hammersmith and Fulham', 'Barnet']) {
// The search page asks for the same first 50 schools.
const res = await page.request.get(`/api/schools?search=${encodeURIComponent(name)}&phase=secondary&page_size=50`);
const schools: Row[] = ((await res.json()).schools ?? []).filter(compared);
const independent = schools.find(s => /independent/i.test(s.school_type ?? ''));
const state = schools.find(s => !/independent/i.test(s.school_type ?? ''));
if (independent && state) { found = { la: name, state, independent }; break; }
}
test.skip(!found, 'no LA here lists a state and an independent secondary on one page');
await page.goto(`/?search=${encodeURIComponent(found!.la)}&phase=secondary`);
const stats = (urn: number) => page.locator(`a[href^="/school/${urn}-"]`).first()
.locator('xpath=ancestor::div[contains(@class, "__rowContent")][1]')
.locator('[class*="__line3"]');
await expect(stats(found!.state.urn).getByText(/vs LA avg/)).toBeVisible({ timeout: 15_000 });
const independent = stats(found!.independent.urn);
await expect(independent.getByText(found!.independent.attainment_8_score!.toFixed(1))).toBeVisible();
await expect(independent.getByText(/vs LA avg/)).toHaveCount(0);
});
test('a secondary shows its 2023/24 GCSE results, the last year DfE published Progress 8', async ({ page }) => {
// DfE re-issued its 2023/24 file under older column names, and every
// school's 2023/24 row loaded empty (audit C2). These are DfE's final
// figures for Bishop Stopford School, so they do not change.
await page.goto('/school/137086');
const history = page.locator('#history');
await history.getByText('View raw year-by-year data').click();
const row = history.getByRole('row', { name: /2023\/24/ });
await expect(row).toContainText('64.1');
await expect(row).toContainText('+1.0');
await expect(row).toContainText('91.7%');
});
test('a phase outside primary/secondary filters to that phase, not to everything', async ({ page }) => {
// The search page offers every GIAS phase, but the API only knew the grouped
// ones and silently dropped the rest — so "Nursery" returned primaries.
@@ -505,6 +554,49 @@ test('a grade confirmed at an ungraded visit says so and is dated by it', async
await expect(section.getByText('Confirmed at an ungraded inspection, 18 July 2024')).toBeVisible();
});
test('a further education college shows its Ofsted result, not "Not yet inspected"', async ({ page }) => {
// Queen Mary's College (145175) is inspected under Ofsted's further education
// framework, which the site did not load: its badge said "Not yet inspected"
// (audit H4). Its result may change; the label must never be absent or wrong.
const res = await page.request.get('/api/schools/145175');
expect(res.ok()).toBeTruthy();
await page.goto('/school/145175');
const ofsted = page.locator('#ofsted');
await expect(ofsted).toBeVisible();
await expect(ofsted.getByText('Further education and skills')).toBeVisible();
await expect(ofsted).not.toContainText('Not yet inspected');
});
test('an ISI-inspected school says so instead of "Not yet inspected"', async ({ page }) => {
const res = await page.request.get('/api/schools?school_type=independent&phase=secondary&page_size=100');
expect(res.ok()).toBeTruthy();
const school = ((await res.json()).schools ?? []).find(
(s: { inspectorate?: string | null; ofsted_date?: string | null; ofsted_rc_date?: string | null }) =>
s.inspectorate === 'ISI' && !s.ofsted_date && !s.ofsted_rc_date);
test.skip(!school, 'no ISI school without an Ofsted record here');
await page.goto(`/school/${school.urn}`);
await expect(page.getByText('Inspected by the Independent Schools Inspectorate (ISI)')).toBeVisible();
await expect(page.locator('#ofsted')).not.toContainText('Not yet inspected');
});
test('"Not yet inspected" appears only for a school Ofsted lists without an inspection', async ({ page }) => {
const res = await page.request.get('/api/schools?phase=primary&page_size=500');
expect(res.ok()).toBeTruthy();
type Row = { urn: number; inspectorate?: string | null; ofsted_listed?: boolean | null; ofsted_date?: string | null; ofsted_rc_date?: string | null; ofsted_grade?: number | null };
const schools: Row[] = (await res.json()).schools ?? [];
// No Ofsted record, and not an ISI school (whose badge names ISI instead).
const uninspected = (s: Row) => !s.ofsted_date && !s.ofsted_rc_date && !s.ofsted_grade && s.inspectorate !== 'ISI';
const unlisted = schools.find(s => !s.ofsted_listed && uninspected(s));
const listed = schools.find(s => s.ofsted_listed && uninspected(s));
test.skip(!listed, 'no listed, uninspected primary on this page');
await page.goto(`/school/${listed!.urn}`);
await expect(page.locator('#ofsted').getByText('Not yet inspected')).toBeVisible();
if (unlisted) {
await page.goto(`/school/${unlisted.urn}`);
await expect(page.getByText('Not yet inspected')).toHaveCount(0);
}
});
test('school detail page renders name and performance data', async ({ page }) => {
await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first();
@@ -69,6 +69,18 @@ const data: Record<string, ComparisonData> = {
};
describe('CompareOfsted', () => {
it('words a school without an Ofsted record from the evidence (audit H4)', () => {
const isi = { ...school(4, 'ISI School'), inspectorate: 'ISI' } as School;
const listed = { ...school(5, 'New Academy'), ofsted_listed: true } as School;
render(<CompareOfsted schools={[isi, listed]} data={{
'4': { school_info: isi, yearly_data: [] } as unknown as ComparisonData,
'5': { school_info: listed, yearly_data: [] } as unknown as ComparisonData,
}} />);
expect(screen.getAllByText('Inspected by ISI').length).toBeGreaterThan(0);
expect(screen.getAllByText('Not yet inspected').length).toBeGreaterThan(0);
expect(screen.queryByText(/No inspection outcome in our dataset/)).toBeNull();
});
it('renders the three regimes without inventing an overall grade for report cards', () => {
render(<CompareOfsted schools={schools} data={data} />);
@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react';
import { InspectionNotice } from '@/components/school/InspectionNotice';
it('names ISI and links its reports for an ISI school (audit H4)', () => {
render(<InspectionNotice school={{ urn: 136083, inspectorate: 'ISI', ofsted_listed: false }} />);
expect(screen.getByText('Inspected by the Independent Schools Inspectorate (ISI)')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ISI reports/ })).toHaveAttribute('href', 'https://www.isi.net/reports/');
});
it('says "Not yet inspected" for a school Ofsted lists without an inspection', () => {
render(<InspectionNotice school={{ urn: 150031, inspectorate: 'Not applicable', ofsted_listed: true }} />);
expect(screen.getByText('Not yet inspected')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Ofsted reports/ })).toHaveAttribute(
'href', 'https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/150031');
});
it('renders nothing without inspection data, including an unknown listing', () => {
const { container } = render(<InspectionNotice school={{ urn: 1, inspectorate: null, ofsted_listed: null }} />);
expect(container).toBeEmptyDOMElement();
});
@@ -105,3 +105,20 @@ it('puts the card back when the pins are rebuilt for a reason other than the sch
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
});
it('compares a state secondary with its LA average on the card, but not an independent one', () => {
const state: School = { ...base, urn: 3, school_name: 'Holland Park School', school_type: 'Academy converter',
phase: 'Secondary', local_authority: 'Kensington and Chelsea', attainment_8_score: 60,
latitude: 51.5, longitude: -0.2, distance: 0.3 };
const independent: School = { ...state, urn: 4, school_name: 'Abbey Gate College',
school_type: 'Other independent school', attainment_8_score: 20.4 };
const laAverages = { 'Kensington and Chelsea': 54.5 };
const { container, rerender } = renderMap({ schools: [state, independent], laAverages, selectedUrn: 3 });
expect(container.querySelector('.sc-popup')).toHaveTextContent('60.0 Att 8 +5.5 vs LA');
rerender({ schools: [state, independent], laAverages, selectedUrn: 4 });
const card = container.querySelector('.sc-popup')!;
expect(card).toHaveTextContent('20.4 Att 8');
expect(card).not.toHaveTextContent(/vs LA/);
});
@@ -82,3 +82,14 @@ it('prints an ungraded outcome when there is no grade (Oakgrove)', () => {
expect(screen.getByText('No overall grade')).toBeInTheDocument();
expect(screen.getByText(/Ungraded inspection, 13 November 2024: Standards maintained/)).toBeInTheDocument();
});
it('labels a further education record and prints its short inspection (audit H4)', () => {
renderSection({
framework: 'Further education and skills',
current_grade: { grade: 2, date: '2019-03-05', basis: 'graded' },
latest_visit: { date: '2023-02-01', kind: 'short', outcome: null },
});
expect(screen.getByText('Further education and skills')).toBeInTheDocument();
expect(screen.getByText('Good')).toBeInTheDocument();
expect(screen.getByText('Latest visit: Short inspection, 1 February 2023')).toBeInTheDocument();
});
@@ -52,3 +52,8 @@ describe('SchoolRow shares the school page flags', () => {
expect(screen.getByText("Boys' school")).toBeInTheDocument();
});
});
it('renders no Ofsted badge for a school with no inspection data (audit H4)', () => {
const { container } = render(<SchoolRow school={{ ...base, ofsted_grade: null, ofsted_date: null, ofsted_listed: false, inspectorate: null }} />);
expect(container.textContent).not.toMatch(/Not yet inspected|Inspected/);
});
@@ -116,3 +116,38 @@ describe('SecondarySchoolRow shares the school page flags', () => {
expect(screen.getByText('Fee-paying')).toBeInTheDocument();
});
});
describe('SecondarySchoolRow LA comparison', () => {
it('compares a state school with its LA average', () => {
render(
<SecondarySchoolRow
school={{ ...base, school_type: 'Academy converter', attainment_8_score: 60 }}
laAvgAttainment8={54.5}
/>,
);
expect(screen.getByText(/\+5\.5 vs LA avg/)).toBeInTheDocument();
});
it('keeps the comparison for a school whose type is unknown', () => {
render(
<SecondarySchoolRow
school={{ ...base, school_type: null, attainment_8_score: 60 } as unknown as School}
laAvgAttainment8={54.5}
/>,
);
expect(screen.getByText(/\+5\.5 vs LA avg/)).toBeInTheDocument();
});
it("shows an independent school's Attainment 8 without an LA comparison", () => {
// DfE's LA average covers state-funded schools; an independent's
// Attainment 8 leaves out IGCSEs (audit H2).
render(
<SecondarySchoolRow
school={{ ...base, school_type: 'Other independent school', attainment_8_score: 20.4 }}
laAvgAttainment8={54.5}
/>,
);
expect(screen.getByText('20.4')).toBeInTheDocument();
expect(screen.queryByText(/vs LA avg/)).not.toBeInTheDocument();
});
});
@@ -19,6 +19,7 @@ import {
summariseAdmissions,
summariseReportCard,
verdict,
noInspectionLabel,
} from '@/lib/compareLogic';
import type { OfstedInspection, SchoolAdmissions } from '@/lib/types';
@@ -359,3 +360,14 @@ describe('basket limits per comparison group', () => {
expect(kept).toEqual([0, 1, 2, 3, 4, 7, 8, 9]);
});
});
it('shows an FE report card with only a safeguarding result as a report card (audit H4)', () => {
const display = ofstedDisplay(ofsted({ rc_inspection_date: '2026-05-12', rc_safeguarding_met: true, report_card: {} }));
expect(display.kind).toBe('report_card');
});
it('words the no-record compare cell from the evidence', () => {
expect(noInspectionLabel({ inspectorate: 'ISI' })).toBe('Inspected by ISI');
expect(noInspectionLabel({ ofsted_listed: true })).toBe('Not yet inspected');
expect(noInspectionLabel({ ofsted_listed: null })).toBe('No inspection in our dataset');
});
+10 -1
View File
@@ -1,4 +1,4 @@
import { gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
import { gradeSourceLine, hasReportCard, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
import type { OfstedInspection } from '@/lib/types';
const base = { report_card: {} } as unknown as OfstedInspection;
@@ -45,3 +45,12 @@ describe('showLatestVisitLine', () => {
latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).toBe(false);
});
});
it('names a short inspection as Ofsted does', () => {
expect(latestVisitLine({ date: '2023-02-01', kind: 'short', outcome: null })).toBe('Short inspection, 1 February 2023');
});
it('treats a report card with only a safeguarding result as a report card (FE)', () => {
expect(hasReportCard({ rc_inspection_date: '2026-05-12', report_card: {} })).toBe(true);
expect(hasReportCard({ rc_inspection_date: null, report_card: {} })).toBe(false);
});
+55 -26
View File
@@ -148,40 +148,40 @@ 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' });
expect(badge.label).toBe('Outstanding · 2023');
expect(badge.cssClass).toBe('ofsted1');
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_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('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');
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');
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');
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');
expect(badge.cssClass).toBe('ofstedRc');
expect(badge!.label).toBe('Report Card · 2026');
expect(badge!.cssClass).toBe('ofstedRc');
});
it('a report card wins over a carried-forward legacy grade', () => {
@@ -194,34 +194,46 @@ describe('buildOfstedListBadge', () => {
ofsted_framework: 'Schools - S5',
ofsted_rc_date: '2026-02-03',
});
expect(badge.label).toBe('Report Card · 2026');
expect(badge.cssClass).toBe('ofstedRc');
expect(badge!.label).toBe('Report Card · 2026');
expect(badge!.cssClass).toBe('ofstedRc');
});
it('returns an "Inspected" badge for an OEIF inspection with no overall grade (post-Sept-2024)', () => {
// Inspected after Sept 2024: inspection on record (date + framework) but
// Ofsted no longer issues an overall grade. Must NOT read as "Not yet inspected".
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2024-11-01', ofsted_framework: 'OEIF' });
expect(badge.label).toBe('Inspected · 2024');
expect(badge.cssClass).toBe('ofstedInspected');
expect(badge!.label).toBe('Inspected · 2024');
expect(badge!.cssClass).toBe('ofstedInspected');
});
it('returns an "Inspected" badge without a year when the grade is missing and date is absent but a record exists', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: 'OEIF' });
expect(badge.label).toBe('Inspected');
expect(badge.cssClass).toBe('ofstedInspected');
expect(badge!.label).toBe('Inspected');
expect(badge!.cssClass).toBe('ofstedInspected');
});
it('returns pending badge when no grade and no inspection on record', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: null });
expect(badge.label).toBe('Not yet inspected');
expect(badge.cssClass).toBe('ofstedPending');
it('says "Not yet inspected" only when Ofsted lists the school uninspected (audit H4)', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_listed: true });
expect(badge).toEqual({ label: 'Not yet inspected', cssClass: 'ofstedPending' });
});
it('returns pending badge when all fields are undefined', () => {
const badge = buildOfstedListBadge({});
expect(badge.label).toBe('Not yet inspected');
expect(badge.cssClass).toBe('ofstedPending');
it('names ISI for a school GIAS says ISI inspects', () => {
expect(buildOfstedListBadge({ ofsted_date: null, inspectorate: 'ISI', ofsted_listed: false }))
.toEqual({ label: 'Inspected by ISI', cssClass: 'ofstedPending' });
});
it('an Ofsted record wins over ISI', () => {
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_grade_date: '2022-03-01', inspectorate: 'ISI' })!.label)
.toBe('Good · 2022');
});
it('gives no badge without any inspection data', () => {
expect(buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: null })).toBeNull();
expect(buildOfstedListBadge({ ofsted_listed: null, inspectorate: 'Not applicable' })).toBeNull();
});
it('gives no badge when all fields are undefined (audit H4)', () => {
expect(buildOfstedListBadge({})).toBeNull();
});
});
@@ -391,3 +403,20 @@ describe('singleSexLabel', () => {
expect(singleSexLabel(undefined)).toBeNull();
});
});
describe('isIndependentSchool', () => {
const { isIndependentSchool } = require('@/lib/utils');
it('matches both GIAS independent types', () => {
expect(isIndependentSchool({ school_type: 'Other independent school' })).toBe(true);
expect(isIndependentSchool({ school_type: 'Other independent special school' })).toBe(true);
});
it('does not match state-funded types or a missing type', () => {
for (const t of ['Academy converter', 'Community school', 'Free schools', 'Non-maintained special school']) {
expect(isIndependentSchool({ school_type: t })).toBe(false);
}
expect(isIndependentSchool({ school_type: null })).toBe(false);
expect(isIndependentSchool({})).toBe(false);
});
});
+3 -3
View File
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { School } from '@/lib/types';
import { schoolUrl, isSpecialSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils';
import { schoolUrl, isSpecialSchool, isIndependentSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils';
interface LeafletMapInnerProps {
schools: School[];
@@ -70,7 +70,7 @@ function metricHtml(school: School, { nationalAvgRwm, laAverages }: CardContext)
const score = school.attainment_8_score;
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
let delta = '';
if (!special && laAvg != null) {
if (!special && !isIndependentSchool(school) && laAvg != null) {
const diff = Math.round((score - laAvg) * 10) / 10;
// Att8 runs 0–90 in 0.1 steps; ±0.5 is meaningful, where RWM needs ±2.
const cls = diff >= 0.5 ? 'sc-up' : diff <= -0.5 ? 'sc-down' : '';
@@ -104,7 +104,7 @@ function cardHtml(school: School, ctx: CardContext): string {
? `${metric ? ' · ' : ''}${school.total_pupils.toLocaleString('en-GB')} pupils` : '';
return `<div class="sc-card">
<div class="sc-card-head"><a class="sc-card-name" href="${href}">${escapeHtml(school.school_name)}</a>${distance}</div>
<div class="sc-card-tags"><span class="sc-ofsted sc-${badge.cssClass}">${escapeHtml(badge.label)}</span>${type}</div>
<div class="sc-card-tags">${badge ? `<span class="sc-ofsted sc-${badge.cssClass}">${escapeHtml(badge.label)}</span>` : ''}${type}</div>
${metric || pupils ? `<div class="sc-card-metric">${metric}${pupils}</div>` : ''}
<div class="sc-card-actions">
<a class="btn btn-tertiary btn-sm" href="${href}">View</a>
+5 -3
View File
@@ -66,9 +66,11 @@ export function SchoolRow({
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
{school.school_name}
</a>
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
{ofstedBadge.label}
</span>
{ofstedBadge && (
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
{ofstedBadge.label}
</span>
)}
</div>
{/* Line 2: Context tags */}
+11 -7
View File
@@ -11,7 +11,7 @@
'use client';
import type { School } from '@/lib/types';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool, isIndependentSchool } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import styles from './SecondarySchoolRow.module.css';
@@ -45,10 +45,12 @@ export function SecondarySchoolRow({
const att8 = school.attainment_8_score;
// The school's own Attainment 8 is a same-school figure — shown whenever it
// exists (special schools included; their type tag on line 2 gives context).
// Only the vs-LA-average delta, a benchmark comparison, is dropped for
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
// Only the vs-LA-average delta, a benchmark comparison, is dropped: for
// special schools / PRUs / AP, whose pupils aren't measured against it
// fairly, and for independent schools, because DfE's LA average covers
// state-funded schools and an independent's Attainment 8 leaves out IGCSEs.
const laDelta =
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
att8 != null && !isSpecialSchool(school) && !isIndependentSchool(school) && laAvgAttainment8 != null
? att8 - laAvgAttainment8
: null;
@@ -65,9 +67,11 @@ export function SecondarySchoolRow({
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
{school.school_name}
</a>
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
{ofstedBadge.label}
</span>
{ofstedBadge && (
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
{ofstedBadge.label}
</span>
)}
</div>
{/* Line 2: Context tags */}
@@ -14,6 +14,7 @@ import {
summariseAdmissions,
verdict,
type ReportCardSummary,
noInspectionLabel,
} from '@/lib/compareLogic';
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
@@ -91,7 +92,7 @@ export function CompareAtAGlance({
No overall grade
</span>
)}
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
{display.kind === 'none' && <span className={s.small}>{noInspectionLabel(data[String(school.urn)]?.school_info ?? school)}</span>}
</Cell>
);
})}
@@ -12,6 +12,7 @@ import {
ofstedDisplay,
rcAreaLabel,
type OfstedDisplay,
noInspectionLabel,
} from '@/lib/compareLogic';
import { gradeSourceLine } from '@/lib/ofstedStatus';
import type { ComparisonData, OfstedCurrentGrade, OfstedInspection, School } from '@/lib/types';
@@ -41,9 +42,11 @@ 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, current, school }: {
display: OfstedDisplay; current: OfstedCurrentGrade | null; school: School;
}) {
if (display.kind === 'none') {
return <span className={s.small}>No inspection outcome in our dataset</span>;
return <span className={s.small}>{noInspectionLabel(school)}</span>;
}
if (display.kind === 'report_card') {
return (
@@ -175,7 +178,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]} current={data[String(school.urn)]?.ofsted?.current_grade ?? null} school={data[String(school.urn)]?.school_info ?? school} />
</Cell>
))}
</Measure>
@@ -213,7 +216,7 @@ export function CompareOfsted({
schoolName={school.school_name}
/>
) : (
<span className={s.small}>No inspection in our dataset</span>
<span className={s.small}>{noInspectionLabel(data[String(school.urn)]?.school_info ?? school)}</span>
)}
</Cell>
);
@@ -228,7 +231,7 @@ export function CompareOfsted({
{schools.map((school, i) => {
const url =
data[String(school.urn)]?.ofsted?.ofsted_page_url ??
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${school.urn}`;
return (
<Cell key={school.urn} school={school} index={i}>
{/* Short visible label to save space on mobile (the coloured
@@ -0,0 +1,46 @@
/**
* InspectionNotice — the school page's inspection section when there is no
* Ofsted inspection record (audit H4): the inspectorate GIAS names (ISI), or
* Ofsted's own "not yet inspected" when its data lists the school. Nothing
* otherwise: no claim the data cannot support. Server component.
*/
import type { School } from '@/lib/types';
import { Section, sectionStyles as styles } from './sectionShared';
const OFSTED_REPORTS = 'https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/';
const ISI_REPORTS = 'https://www.isi.net/reports/';
export function InspectionNotice({ school }: { school: Pick<School, 'urn' | 'inspectorate' | 'ofsted_listed'> }) {
if (school.inspectorate === 'ISI') {
return (
<Section id="ofsted">
<h2 className={styles.sectionTitle}>
Inspection
<a href={ISI_REPORTS} target="_blank" rel="noopener noreferrer" className={styles.ofstedReportLink}
data-umami-event="external_link_clicked" data-umami-event-target="isi">
ISI reports ↗
</a>
</h2>
<p className={styles.ofstedDisclaimer}>Inspected by the Independent Schools Inspectorate (ISI)</p>
</Section>
);
}
if (school.ofsted_listed) {
return (
<Section id="ofsted">
<h2 className={styles.sectionTitle}>
Ofsted Rating
<a href={`${OFSTED_REPORTS}${school.urn}`} target="_blank" rel="noopener noreferrer"
className={styles.ofstedReportLink} data-umami-event="external_link_clicked" data-umami-event-target="ofsted">
Ofsted reports ↗
</a>
</h2>
<div className={styles.ofstedHeader}>
<span className={styles.ofstedGrade}>Not yet inspected</span>
</div>
</Section>
);
}
return null;
}
@@ -12,7 +12,7 @@
*/
import { ofstedDisplay } from '@/lib/compareLogic';
import { formatOfstedDate, gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
import { FE_FRAMEWORK, formatOfstedDate, gradeSourceLine, latestVisitLine, showLatestVisitLine } from '@/lib/ofstedStatus';
import type { OfstedInspection } from '@/lib/types';
import { Section, sectionStyles as styles } from './sectionShared';
@@ -71,6 +71,10 @@ export function OfstedSection({
</a>
</h2>
{ofsted.framework === FE_FRAMEWORK && (
<p className={styles.ofstedDisclaimer}>{FE_FRAMEWORK}</p>
)}
{isReportCard ? (
/* ── New Report Card layout ── */
<>
@@ -19,6 +19,8 @@ import type {
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SchoolFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { InspectionNotice } from './InspectionNotice';
import { hasReportCard } from '@/lib/ofstedStatus';
import { ResultsSection } from './ResultsSection';
import { AdmissionsSection } from './AdmissionsSection';
import { DistanceSection } from './DistanceSection';
@@ -54,7 +56,8 @@ export function PrimarySchoolSections({
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
// By date as well as area labels: an FE report card has only a safeguarding result (audit H4).
const isReportCard = !!ofsted && hasReportCard(ofsted);
// 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.
@@ -71,7 +74,7 @@ export function PrimarySchoolSections({
return (
<>
{ofsted && (
{ofsted ? (
<OfstedSection
ofsted={ofsted}
urn={schoolInfo.urn}
@@ -80,7 +83,7 @@ export function PrimarySchoolSections({
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
/>
)}
) : <InspectionNotice school={schoolInfo} />}
{flags.hasAnyResults && flags.latestResults && (
<ResultsSection
@@ -19,6 +19,8 @@ import type {
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SecondaryFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { InspectionNotice } from './InspectionNotice';
import { hasReportCard } from '@/lib/ofstedStatus';
import { GcseSection } from './GcseSection';
import { DestinationsSection } from './DestinationsSection';
import { Post16DestinationsSection } from './Post16DestinationsSection';
@@ -60,7 +62,8 @@ export function SecondarySchoolSections({
}: SecondarySchoolSectionsProps) {
const secondaryAvg = nationalAvg?.secondary ?? {};
const isReportCard = !!(ofsted?.report_card && Object.keys(ofsted.report_card).length > 0);
// By date as well as area labels: an FE report card has only a safeguarding result (audit H4).
const isReportCard = !!ofsted && hasReportCard(ofsted);
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.latest_visit?.date ?? null;
@@ -74,7 +77,7 @@ export function SecondarySchoolSections({
return (
<div className={styles.secondaryScope}>
{ofsted && (
{ofsted ? (
<OfstedSection
ofsted={ofsted}
urn={schoolInfo.urn}
@@ -83,7 +86,7 @@ export function SecondarySchoolSections({
oeifAllSameGrade={oeifAllSameGrade}
oeifAreas={oeifAreas}
/>
)}
) : <InspectionNotice school={schoolInfo} />}
{flags.hasResults && flags.latestResults && (
<GcseSection
+13 -2
View File
@@ -10,7 +10,8 @@
* methodology instead of thresholding point estimates.
*/
import type { OfstedInspection, SchoolAdmissions } from './types';
import { hasReportCard } from './ofstedStatus';
import type { OfstedInspection, School, SchoolAdmissions } from './types';
// ---------------------------------------------------------------------------
// Verdicts against an anchor (England average or state-school benchmark)
@@ -104,6 +105,16 @@ export type OfstedDisplay =
| { kind: 'no_overall_grade' }
| { kind: 'report_card'; summary: ReportCardSummary };
/**
* The compare cell when a school has no Ofsted record (audit H4): ISI per GIAS,
* Ofsted's own "not yet inspected", or plainly that we hold nothing.
*/
export function noInspectionLabel(school: Pick<School, 'inspectorate' | 'ofsted_listed'>): string {
if (school.inspectorate === 'ISI') return 'Inspected by ISI';
if (school.ofsted_listed) return 'Not yet inspected';
return 'No inspection in our dataset';
}
export function ofstedDisplay(
ofsted: OfstedInspection | null | undefined,
): OfstedDisplay {
@@ -111,7 +122,7 @@ export function ofstedDisplay(
// A report card is the newest inspection format; when present it wins —
// never derive or prefer an overall grade alongside it.
if (ofsted.report_card && Object.keys(ofsted.report_card).length > 0) {
if (hasReportCard(ofsted)) {
return { kind: 'report_card', summary: summariseReportCard(ofsted) };
}
+14
View File
@@ -17,8 +17,22 @@ const VISIT_KIND: Record<OfstedLatestVisit['kind'], string> = {
report_card: 'Report card inspection',
graded: 'Graded inspection',
ungraded: 'Ungraded inspection',
short: 'Short inspection',
};
/** fact_ofsted_latest.framework for Ofsted's further education and skills records (audit H4). */
export const FE_FRAMEWORK = 'Further education and skills';
/**
* A report card is in force. Checked by its date as well as its area labels:
* an FE report card carries a safeguarding result and no school areas.
*/
export function hasReportCard(
ofsted: Pick<OfstedInspection, 'rc_inspection_date' | 'report_card'>,
): boolean {
return !!ofsted.rc_inspection_date || Object.keys(ofsted.report_card ?? {}).length > 0;
}
/** "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);
+7 -2
View File
@@ -86,6 +86,10 @@ export interface School {
/** The school's latest inspection of any kind. */
ofsted_date?: string | null;
ofsted_framework?: string | null;
/** The inspectorate GIAS names ("ISI", "Ofsted", "Not applicable"). Audit H4. */
inspectorate?: string | null;
/** Whether Ofsted's own data lists the school, inspected or not. Audit H4. */
ofsted_listed?: boolean | null;
}
// ============================================================================
@@ -93,7 +97,8 @@ export interface School {
// ============================================================================
export interface OfstedInspection {
framework: 'OEIF' | 'ReportCard' | null;
/** Ofsted's event grouping (e.g. "Schools - S5"), or "Further education and skills" for FE records (audit H4). */
framework: string | null;
inspection_date: string | null;
/** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
rc_inspection_date?: string | null;
@@ -143,7 +148,7 @@ export interface OfstedCurrentGrade {
export interface OfstedLatestVisit {
date: string;
kind: 'report_card' | 'graded' | 'ungraded';
kind: 'report_card' | 'graded' | 'ungraded' | 'short';
outcome: string | null;
}
+19 -3
View File
@@ -678,7 +678,9 @@ export interface OfstedListBadge {
* 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
* - No inspection on record (audit H4): "Inspected by ISI" when GIAS names
* ISI; "Not yet inspected" only when Ofsted's own data lists the school;
* otherwise no badge, never a claim the data cannot support.
*/
export function buildOfstedListBadge(school: {
ofsted_grade?: 1 | 2 | 3 | 4 | null;
@@ -686,7 +688,9 @@ export function buildOfstedListBadge(school: {
ofsted_date?: string | null;
ofsted_framework?: string | null;
ofsted_rc_date?: string | null;
}): OfstedListBadge {
inspectorate?: string | null;
ofsted_listed?: boolean | null;
}): OfstedListBadge | null {
// A report card wins over any carried-forward legacy grade — signalled by
// ofsted_rc_date. ofsted_framework is the raw event grouping ("Schools -
// S5"), never "ReportCard", so it can't detect report cards.
@@ -719,7 +723,9 @@ export function buildOfstedListBadge(school: {
return { label: `Inspected${visitYear ? ` · ${visitYear}` : ''}`, cssClass: 'ofstedInspected' };
}
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
if (school.inspectorate === 'ISI') return { label: 'Inspected by ISI', cssClass: 'ofstedPending' };
if (school.ofsted_listed) return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
return null;
}
// ============================================================================
@@ -758,6 +764,16 @@ export function isSpecialSchool(school: { school_type?: string | null }): boolea
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
}
/**
* Independent (fee-paying) schools: GIAS types "Other independent school" and
* "Other independent special school". DfE's Attainment 8 for them leaves out
* IGCSEs, and DfE's LA averages cover state-funded schools only, so callers
* drop the "vs LA avg" comparison for them (audit H2).
*/
export function isIndependentSchool(school: { school_type?: string | null }): boolean {
return /\bindependent\b/i.test(school.school_type ?? '');
}
/**
* Whether GIAS records a religious character. "None", "Does not apply" and
* "Not applicable" are the register's ways of saying it has none; the place