Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32f8a02862 | ||
|
|
7e3096c881 | ||
|
|
1f80571b1f | ||
|
|
3132f07af2 | ||
|
|
8a9ba30cc2 | ||
|
|
388b496cc5 | ||
|
|
15da060e4a | ||
|
|
5981916b82 | ||
|
|
a5b2296240 | ||
|
|
af23fbf27e | ||
|
|
22e9eb2d48 | ||
|
|
13253116c2 | ||
|
|
1b8adb91ee | ||
|
|
ca4fc647a5 | ||
|
|
261403bcd2 | ||
|
|
e4c8b86fe7 | ||
|
|
1c1df77961 | ||
|
|
993822d769 | ||
|
|
0b36eace9d | ||
|
|
de943ded19 | ||
|
|
ae6ef6860b | ||
|
|
4e316b4e38 | ||
|
|
733faefb98 | ||
|
|
5e370d09f1 | ||
|
|
f6bb037c47 | ||
|
|
9c93c3d9c2 | ||
|
|
17bd4d5a5e | ||
|
|
31ae13451a | ||
|
|
4e6be0ce65 | ||
|
|
452ec77449 | ||
|
|
aa87fa917d | ||
|
|
e36125b24a | ||
|
|
fdda52ff0a | ||
|
|
a2c63b3cda | ||
|
|
b2b2cad5ac | ||
|
|
5ec4f3f7cd | ||
|
|
74bdbebd57 | ||
|
|
b31e71ac88 | ||
|
|
b05f3bebc7 | ||
|
|
ce422e6436 | ||
|
|
2ac26acf91 | ||
|
|
3e77057567 | ||
|
|
e0d5a9969e | ||
|
|
dc21e80a5e | ||
|
|
6d3a203699 | ||
|
|
b0c5b6bb57 | ||
|
|
a1128bd801 | ||
|
|
2fd997bfe6 | ||
|
|
bdd9bef349 | ||
|
|
e5f7f4c959 | ||
|
|
60918da483 | ||
|
|
284215fbce | ||
|
|
200a97d0b9 | ||
|
|
f3fa12806b | ||
|
|
1004f08daf | ||
|
|
79246edc22 | ||
|
|
64b63b96c8 | ||
|
|
5944d88f0b | ||
|
|
163b501be6 | ||
|
|
80176cac4d | ||
|
|
84baf95f68 | ||
|
|
99b769ca9e | ||
|
|
e8f78a1598 | ||
|
|
8e0b730629 | ||
|
|
20a27f3958 | ||
|
|
852ed11e4d | ||
|
|
77d7052662 | ||
|
|
c9e324635b | ||
|
|
1d855f3c17 | ||
|
|
9773483221 | ||
|
|
e00a1b38a8 | ||
|
|
026a7ab6aa | ||
|
|
a86a2be96c |
@@ -67,9 +67,12 @@ jobs:
|
|||||||
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
|
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
|
||||||
|
|
||||||
- name: Verify the staging E2E gate passed for this commit
|
- name: Verify the staging E2E gate passed for this commit
|
||||||
|
# Use the built-in Actions token (GITHUB_TOKEN is the documented name;
|
||||||
|
# it carries repository read scope), NOT REGISTRY_TOKEN — the registry
|
||||||
|
# token has no repo scope, so the commit-status API returns 403.
|
||||||
run: |
|
run: |
|
||||||
STATUS_JSON=$(curl -fsS \
|
STATUS_JSON=$(curl -fsS \
|
||||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
|
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
|
||||||
echo "$STATUS_JSON" | python3 -c "
|
echo "$STATUS_JSON" | python3 -c "
|
||||||
import json, sys
|
import json, sys
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
venv
|
venv
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
|
# dbt local build artifacts (embed absolute paths + anonymous-usage UUID)
|
||||||
|
pipeline/transform/target/
|
||||||
|
pipeline/transform/logs/
|
||||||
|
pipeline/transform/.user.yml
|
||||||
|
|||||||
+39
-23
@@ -677,6 +677,7 @@ async def compare_schools(
|
|||||||
"deprivation": None,
|
"deprivation": None,
|
||||||
}
|
}
|
||||||
supplementary_by_urn: dict = {}
|
supplementary_by_urn: dict = {}
|
||||||
|
census_benchmarks = None
|
||||||
db = None
|
db = None
|
||||||
try:
|
try:
|
||||||
db = database.SessionLocal()
|
db = database.SessionLocal()
|
||||||
@@ -688,6 +689,31 @@ async def compare_schools(
|
|||||||
key: supp.get(key, default)
|
key: supp.get(key, default)
|
||||||
for key, default in _EMPTY_SUPPLEMENTARY.items()
|
for key, default in _EMPTY_SUPPLEMENTARY.items()
|
||||||
}
|
}
|
||||||
|
# Import-time census context benchmarks (fact_census_benchmarks);
|
||||||
|
# absent mart → None, and compute_benchmarks leaves those fields null.
|
||||||
|
try:
|
||||||
|
from .models import CensusBenchmark
|
||||||
|
|
||||||
|
rows = db.query(CensusBenchmark).all()
|
||||||
|
by_phase = {
|
||||||
|
r.phase: {
|
||||||
|
"year": r.year,
|
||||||
|
"fsm_pct": r.fsm_pct,
|
||||||
|
"eal_pct": r.eal_pct,
|
||||||
|
"median_pupils": r.median_pupils,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
if getattr(r, "phase", None) in ("primary", "secondary")
|
||||||
|
}
|
||||||
|
if by_phase:
|
||||||
|
census_benchmarks = by_phase
|
||||||
|
except Exception:
|
||||||
|
# Missing mart (or a stubbed session in tests) must never break
|
||||||
|
# the compare payload — and not every session has rollback().
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
supplementary_by_urn = {}
|
supplementary_by_urn = {}
|
||||||
finally:
|
finally:
|
||||||
@@ -714,6 +740,9 @@ async def compare_schools(
|
|||||||
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
|
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
|
||||||
"age_range": convert_to_native(latest.get("age_range")),
|
"age_range": convert_to_native(latest.get("age_range")),
|
||||||
"gender": convert_to_native(latest.get("gender")),
|
"gender": convert_to_native(latest.get("gender")),
|
||||||
|
# Needed by the admissions "What this means" copy: selective
|
||||||
|
# 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")),
|
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
|
||||||
"capacity": convert_to_native(latest.get("capacity")),
|
"capacity": convert_to_native(latest.get("capacity")),
|
||||||
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
||||||
@@ -728,7 +757,7 @@ async def compare_schools(
|
|||||||
# Official DfE anchors + computed state-school benchmarks so the
|
# Official DfE anchors + computed state-school benchmarks so the
|
||||||
# compare UI can label provenance correctly (spec §8.6).
|
# compare UI can label provenance correctly (spec §8.6).
|
||||||
"national_averages": _national_averages_payload(df),
|
"national_averages": _national_averages_payload(df),
|
||||||
"benchmarks": compute_benchmarks(df),
|
"benchmarks": compute_benchmarks(df, census_benchmarks=census_benchmarks),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -778,6 +807,10 @@ async def get_la_averages(request: Request):
|
|||||||
_KS2_NATIONAL_METRICS = [
|
_KS2_NATIONAL_METRICS = [
|
||||||
"rwm_expected_pct", "rwm_high_pct",
|
"rwm_expected_pct", "rwm_high_pct",
|
||||||
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
||||||
|
# Per-subject higher-standard nationals: reading/maths reach the "higher
|
||||||
|
# standard" in the tests; writing is teacher-assessed at "greater depth"
|
||||||
|
# (writing_gd_pct). Needed so each SATs bar compares to its own benchmark.
|
||||||
|
"reading_high_pct", "writing_gd_pct", "maths_high_pct",
|
||||||
"gps_expected_pct", "gps_high_pct", "science_expected_pct",
|
"gps_expected_pct", "gps_high_pct", "science_expected_pct",
|
||||||
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||||
"reading_progress", "writing_progress", "maths_progress",
|
"reading_progress", "writing_progress", "maths_progress",
|
||||||
@@ -797,11 +830,11 @@ def _national_averages_payload(df: pd.DataFrame) -> dict:
|
|||||||
/api/compare.
|
/api/compare.
|
||||||
|
|
||||||
Both series are persisted marts computed at import time: official DfE
|
Both series are persisted marts computed at import time: official DfE
|
||||||
KS2 figures (fact_ks2_national_averages) and dataset-computed KS4
|
KS2 figures (fact_ks2_national_averages) and official DfE KS4 figures
|
||||||
averages (fact_ks4_national_averages) — the API never aggregates the
|
(fact_ks4_national_averages) — the API never aggregates the performance
|
||||||
performance dataframe per request. If the KS4 mart hasn't been built
|
dataframe per request. If the KS4 mart hasn't been built yet, the
|
||||||
yet (deploy lands before the next DAG run), fall back to computing the
|
secondary series is empty — never a computed stand-in, because the UI
|
||||||
latest year only — a single-year scan, never the historical loop.
|
labels these figures as official DfE data.
|
||||||
"""
|
"""
|
||||||
if df.empty:
|
if df.empty:
|
||||||
return {"primary": {}, "secondary": {}}
|
return {"primary": {}, "secondary": {}}
|
||||||
@@ -841,23 +874,6 @@ def _national_averages_payload(df: pd.DataFrame) -> dict:
|
|||||||
primary_by_year = {r.year: _row_metrics(r, _KS2_NATIONAL_METRICS) for r in ks2_rows}
|
primary_by_year = {r.year: _row_metrics(r, _KS2_NATIONAL_METRICS) for r in ks2_rows}
|
||||||
secondary_by_year = {r.year: _row_metrics(r, _KS4_NATIONAL_METRICS) for r in ks4_rows}
|
secondary_by_year = {r.year: _row_metrics(r, _KS4_NATIONAL_METRICS) for r in ks4_rows}
|
||||||
|
|
||||||
if not any(secondary_by_year.values()):
|
|
||||||
# KS4 mart missing/empty: compute the latest year only.
|
|
||||||
df_latest = df[df["year"] == latest_year]
|
|
||||||
sec = (
|
|
||||||
df_latest[df_latest["attainment_8_score"].notna()]
|
|
||||||
if "attainment_8_score" in df_latest.columns
|
|
||||||
else df_latest.iloc[0:0]
|
|
||||||
)
|
|
||||||
vals = {}
|
|
||||||
for col in _KS4_NATIONAL_METRICS:
|
|
||||||
if col in sec.columns:
|
|
||||||
v = sec[col].dropna()
|
|
||||||
if len(v) > 0:
|
|
||||||
vals[col] = round(float(v.mean()), 2)
|
|
||||||
if vals:
|
|
||||||
secondary_by_year[latest_year] = vals
|
|
||||||
|
|
||||||
all_years = sorted(set(primary_by_year) | set(secondary_by_year))
|
all_years = sorted(set(primary_by_year) | set(secondary_by_year))
|
||||||
by_year = [
|
by_year = [
|
||||||
{
|
{
|
||||||
|
|||||||
+29
-15
@@ -172,6 +172,7 @@ _MAIN_QUERY = text("""
|
|||||||
foi.ofsted_grade,
|
foi.ofsted_grade,
|
||||||
foi.ofsted_date,
|
foi.ofsted_date,
|
||||||
foi.ofsted_framework,
|
foi.ofsted_framework,
|
||||||
|
foi.ofsted_rc_date,
|
||||||
l.local_authority_name AS local_authority,
|
l.local_authority_name AS local_authority,
|
||||||
l.local_authority_code,
|
l.local_authority_code,
|
||||||
l.address_line1 AS address1,
|
l.address_line1 AS address1,
|
||||||
@@ -256,7 +257,12 @@ _MAIN_QUERY = text("""
|
|||||||
-- Fall back to the ungraded-inspection grade when no graded grade exists.
|
-- Fall back to the ungraded-inspection grade when no graded grade exists.
|
||||||
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
|
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
|
||||||
inspection_date AS ofsted_date,
|
inspection_date AS ofsted_date,
|
||||||
framework AS ofsted_framework
|
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
|
FROM marts.fact_ofsted_inspection
|
||||||
ORDER BY urn, inspection_date DESC NULLS LAST
|
ORDER BY urn, inspection_date DESC NULLS LAST
|
||||||
) foi ON s.urn = foi.urn
|
) foi ON s.urn = foi.urn
|
||||||
@@ -525,13 +531,20 @@ def get_data_info(db: Session = None) -> dict:
|
|||||||
# SUPPLEMENTARY DATA — per-school detail page
|
# SUPPLEMENTARY DATA — per-school detail page
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def compute_benchmarks(df: pd.DataFrame) -> dict:
|
def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None) -> dict:
|
||||||
"""State-school benchmarks computed from our dataset (spec §5/§8.6).
|
"""State-school benchmarks computed from our dataset (spec §5/§8.6).
|
||||||
|
|
||||||
NOT official DfE figures — consumers must label them
|
NOT official DfE figures — consumers must label them
|
||||||
"state-school average (computed from our dataset)". The disadvantaged
|
"state-school average (computed from our dataset)". The disadvantaged
|
||||||
attainment average is weighted by cohort size (eligible_pupils) so
|
attainment average is weighted by cohort size (eligible_pupils) so
|
||||||
small schools don't dominate; context measures are medians.
|
small schools don't dominate.
|
||||||
|
|
||||||
|
Context measures (FSM/EAL/pupil counts) come from `census_benchmarks`
|
||||||
|
(the fact_census_benchmarks mart, pupil-weighted, keyed by phase): the
|
||||||
|
performance df has no fsm_pct at all, and its eal/disadvantaged columns
|
||||||
|
are KS2-only — medianing them for "secondary" produced junk anchors
|
||||||
|
from the handful of all-through schools. When the mart is unavailable
|
||||||
|
these are None; never fall back across measure definitions.
|
||||||
"""
|
"""
|
||||||
if df.empty or "year" not in df.columns:
|
if df.empty or "year" not in df.columns:
|
||||||
return {}
|
return {}
|
||||||
@@ -567,18 +580,14 @@ def compute_benchmarks(df: pd.DataFrame) -> dict:
|
|||||||
)
|
)
|
||||||
return round(float(w), 1)
|
return round(float(w), 1)
|
||||||
|
|
||||||
def _block(sub, with_disadvantaged):
|
def _block(sub, phase, with_disadvantaged):
|
||||||
median_pupils = None
|
census = (census_benchmarks or {}).get(phase) or {}
|
||||||
if "total_pupils" in sub.columns:
|
|
||||||
mp = sub["total_pupils"].median()
|
|
||||||
if pd.notna(mp):
|
|
||||||
median_pupils = int(mp)
|
|
||||||
block = {
|
block = {
|
||||||
"eal_pct": _median(sub, "eal_pct"),
|
"eal_pct": census.get("eal_pct"),
|
||||||
"sen_support_pct": _median(sub, "sen_support_pct"),
|
"sen_support_pct": _median(sub, "sen_support_pct"),
|
||||||
"disadvantaged_pct": _median(sub, "disadvantaged_pct"),
|
"disadvantaged_pct": _median(sub, "disadvantaged_pct") if with_disadvantaged else None,
|
||||||
"fsm_pct": _median(sub, "fsm_pct"),
|
"fsm_pct": census.get("fsm_pct"),
|
||||||
"median_pupils": median_pupils,
|
"median_pupils": census.get("median_pupils"),
|
||||||
}
|
}
|
||||||
if with_disadvantaged:
|
if with_disadvantaged:
|
||||||
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
|
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
|
||||||
@@ -587,8 +596,8 @@ def compute_benchmarks(df: pd.DataFrame) -> dict:
|
|||||||
return {
|
return {
|
||||||
"source": "state-school average (computed from our dataset)",
|
"source": "state-school average (computed from our dataset)",
|
||||||
"year": int(latest_year),
|
"year": int(latest_year),
|
||||||
"primary": _block(prim, with_disadvantaged=True),
|
"primary": _block(prim, "primary", with_disadvantaged=True),
|
||||||
"secondary": _block(sec, with_disadvantaged=False),
|
"secondary": _block(sec, "secondary", with_disadvantaged=False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -617,6 +626,11 @@ def _ofsted_block(o, urn: int) -> dict:
|
|||||||
block = {
|
block = {
|
||||||
"framework": o.framework,
|
"framework": o.framework,
|
||||||
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
|
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
|
||||||
|
"rc_inspection_date": (
|
||||||
|
o.rc_inspection_date.isoformat()
|
||||||
|
if getattr(o, "rc_inspection_date", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
"inspection_type": o.inspection_type,
|
"inspection_type": o.inspection_type,
|
||||||
"overall_effectiveness": overall,
|
"overall_effectiveness": overall,
|
||||||
"grade_source": grade_source,
|
"grade_source": grade_source,
|
||||||
|
|||||||
+23
-1
@@ -156,6 +156,9 @@ class FactOfstedInspection(Base):
|
|||||||
rc_leadership_governance = Column(Integer)
|
rc_leadership_governance = Column(Integer)
|
||||||
rc_early_years = Column(Integer)
|
rc_early_years = Column(Integer)
|
||||||
rc_sixth_form = Column(Integer)
|
rc_sixth_form = Column(Integer)
|
||||||
|
# Start date of the report-card inspection itself (renewed framework,
|
||||||
|
# Nov 2025+). Null for rows without report-card grades.
|
||||||
|
rc_inspection_date = Column(Date)
|
||||||
report_url = Column(Text)
|
report_url = Column(Text)
|
||||||
|
|
||||||
|
|
||||||
@@ -231,8 +234,27 @@ class FactFinance(Base):
|
|||||||
premises_cost_pct = Column(Float)
|
premises_cost_pct = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class CensusBenchmark(Base):
|
||||||
|
"""State-school context benchmarks from the pupil census — one row per phase.
|
||||||
|
|
||||||
|
fsm_pct / eal_pct are pupil-weighted means. Computed at import time;
|
||||||
|
consumers label them "state-school average (computed from our dataset)".
|
||||||
|
"""
|
||||||
|
__tablename__ = "fact_census_benchmarks"
|
||||||
|
__table_args__ = MARTS
|
||||||
|
|
||||||
|
phase = Column(String(20), primary_key=True)
|
||||||
|
year = Column(Integer)
|
||||||
|
fsm_pct = Column(Float)
|
||||||
|
eal_pct = Column(Float)
|
||||||
|
median_pupils = Column(Integer)
|
||||||
|
|
||||||
|
|
||||||
class Ks4NationalAverage(Base):
|
class Ks4NationalAverage(Base):
|
||||||
"""Computed national KS4 averages (from our dataset) — one row per year."""
|
"""Official DfE KS4 national headline averages — one row per academic year.
|
||||||
|
|
||||||
|
gcse_grade_91_pct has no official national series and is always NULL.
|
||||||
|
"""
|
||||||
__tablename__ = "fact_ks4_national_averages"
|
__tablename__ = "fact_ks4_national_averages"
|
||||||
__table_args__ = MARTS
|
__table_args__ = MARTS
|
||||||
|
|
||||||
|
|||||||
@@ -550,6 +550,7 @@ SCHOOL_COLUMNS = [
|
|||||||
"ofsted_grade",
|
"ofsted_grade",
|
||||||
"ofsted_date",
|
"ofsted_date",
|
||||||
"ofsted_framework",
|
"ofsted_framework",
|
||||||
|
"ofsted_rc_date",
|
||||||
"latitude",
|
"latitude",
|
||||||
"longitude",
|
"longitude",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -57,19 +57,40 @@ def test_weighted_disadvantaged_average():
|
|||||||
def test_medians_ignore_nan_and_older_years():
|
def test_medians_ignore_nan_and_older_years():
|
||||||
b = compute_benchmarks(_df())
|
b = compute_benchmarks(_df())
|
||||||
assert b["year"] == LATEST
|
assert b["year"] == LATEST
|
||||||
# eal medians over [10,20,30,40,50] = 30
|
# sen medians over [10,14,18,20,22] = 18 — the only context measure still
|
||||||
assert b["primary"]["eal_pct"] == 30.0
|
# sourced from the performance df (the rest come from the census mart).
|
||||||
# fsm medians over [15,17,19,21,23] = 19
|
assert b["primary"]["sen_support_pct"] == 18.0
|
||||||
assert b["primary"]["fsm_pct"] == 19.0
|
# disadvantaged_pct medians over [20,24,30,40,44] = 30
|
||||||
# median pupils over [200,280,300,350,400] = 300
|
assert b["primary"]["disadvantaged_pct"] == 30.0
|
||||||
assert b["primary"]["median_pupils"] == 300
|
|
||||||
|
|
||||||
|
def test_benchmarks_use_census_mart_for_context():
|
||||||
|
census = {
|
||||||
|
"primary": {"year": LATEST, "fsm_pct": 25.3, "eal_pct": 21.8, "median_pupils": 240},
|
||||||
|
"secondary": {"year": LATEST, "fsm_pct": 24.1, "eal_pct": 18.9, "median_pupils": 980},
|
||||||
|
}
|
||||||
|
b = compute_benchmarks(_df(), census_benchmarks=census)
|
||||||
|
assert b["primary"]["fsm_pct"] == 25.3
|
||||||
|
assert b["primary"]["eal_pct"] == 21.8
|
||||||
|
assert b["secondary"]["eal_pct"] == 18.9
|
||||||
|
assert b["secondary"]["median_pupils"] == 980
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmarks_context_none_when_mart_missing():
|
||||||
|
# The performance df has no fsm_pct and its eal/disadvantaged columns are
|
||||||
|
# KS2-only — never silently fall back to medianing them for context.
|
||||||
|
b = compute_benchmarks(_df(), census_benchmarks=None)
|
||||||
|
assert b["primary"]["fsm_pct"] is None
|
||||||
|
assert b["primary"]["eal_pct"] is None
|
||||||
|
assert b["primary"]["median_pupils"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_secondary_block_has_no_disadvantaged_rwm():
|
def test_secondary_block_has_no_disadvantaged_rwm():
|
||||||
b = compute_benchmarks(_df())
|
b = compute_benchmarks(_df())
|
||||||
assert "disadvantaged_rwm_expected_pct" not in b["secondary"]
|
assert "disadvantaged_rwm_expected_pct" not in b["secondary"]
|
||||||
assert b["secondary"]["fsm_pct"] == 13.0
|
# KS2-only columns must not produce a fake secondary disadvantaged anchor
|
||||||
assert b["secondary"]["median_pupils"] == 1100
|
# (the old median over all-through schools' KS2 rows produced 50%).
|
||||||
|
assert b["secondary"]["disadvantaged_pct"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_provenance_string():
|
def test_provenance_string():
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""_national_averages_payload reads persisted marts (computed at import
|
"""_national_averages_payload reads persisted marts (computed at import
|
||||||
time) — it must never loop the dataframe per year. The only dataframe work
|
time) — it must never aggregate the dataframe. Both marts hold OFFICIAL
|
||||||
allowed is the single-latest-year KS4 fallback for the window between a
|
DfE figures, so a missing KS4 mart yields an empty secondary series —
|
||||||
deploy and the next DAG run."""
|
never a computed stand-in the UI would mislabel as official."""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -25,6 +25,11 @@ class _Ks2Row:
|
|||||||
year = LATEST
|
year = LATEST
|
||||||
rwm_expected_pct = 62.1
|
rwm_expected_pct = 62.1
|
||||||
gps_expected_pct = 72.0
|
gps_expected_pct = 72.0
|
||||||
|
# Per-subject higher-standard nationals — reading/maths reach the higher
|
||||||
|
# standard, writing is teacher-assessed at greater depth (writing_gd_pct).
|
||||||
|
reading_high_pct = 29.0
|
||||||
|
writing_gd_pct = 13.0
|
||||||
|
maths_high_pct = 24.0
|
||||||
|
|
||||||
|
|
||||||
class _Ks4Row:
|
class _Ks4Row:
|
||||||
@@ -84,10 +89,21 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
|
|||||||
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
|
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
|
||||||
|
|
||||||
|
|
||||||
def test_missing_ks4_mart_falls_back_to_latest_year_only(payload):
|
def test_per_subject_higher_standard_nationals_are_surfaced(payload):
|
||||||
|
# The SATs chart compares each bar to its own benchmark, so the per-subject
|
||||||
|
# higher-standard / greater-depth nationals must reach the payload — not
|
||||||
|
# only the combined rwm_high_pct.
|
||||||
|
body = payload(_StubSession)
|
||||||
|
assert body["primary"]["reading_high_pct"] == 29.0
|
||||||
|
assert body["primary"]["writing_gd_pct"] == 13.0
|
||||||
|
assert body["primary"]["maths_high_pct"] == 24.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ks4_secondary_empty_when_mart_missing(payload):
|
||||||
|
# No computed stand-in: the UI labels national figures as official DfE
|
||||||
|
# data, so an empty mart must yield an empty secondary series.
|
||||||
body = payload(_Ks4MissingSession)
|
body = payload(_Ks4MissingSession)
|
||||||
# Fallback computes the latest year from the df: mean(50, 30) = 40.0
|
assert body["secondary"] == {}
|
||||||
assert body["secondary"]["attainment_8_score"] == 40.0
|
assert all(not e["secondary"] for e in body["by_year"])
|
||||||
# ...and only the latest year — no historical KS4 loop
|
# The KS2 series is unaffected.
|
||||||
ks4_years = [e["year"] for e in body["by_year"] if e["secondary"]]
|
assert body["primary"]["rwm_expected_pct"] == 62.1
|
||||||
assert ks4_years == [LATEST]
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ labels, provider-page URL, graded-vs-carried-forward provenance, and the
|
|||||||
admissions preference/cross-LA detail promoted in the data-foundation PR."""
|
admissions preference/cross-LA detail promoted in the data-foundation PR."""
|
||||||
|
|
||||||
import types
|
import types
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from backend.data_loader import _admissions_row_dict, _ofsted_block
|
from backend.data_loader import _admissions_row_dict, _ofsted_block
|
||||||
|
|
||||||
@@ -40,6 +41,25 @@ def test_grade_source_graded_vs_carried_forward():
|
|||||||
assert _ofsted_block(_row(), urn=1)["grade_source"] is None
|
assert _ofsted_block(_row(), urn=1)["grade_source"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ofsted_block_carries_rc_inspection_date():
|
||||||
|
o = _row(
|
||||||
|
ungraded_grade=2,
|
||||||
|
rc_achievement=1,
|
||||||
|
rc_inspection_date=date(2026, 2, 3),
|
||||||
|
inspection_date=date(2021, 10, 7),
|
||||||
|
)
|
||||||
|
block = _ofsted_block(o, urn=138690)
|
||||||
|
assert block["rc_inspection_date"] == "2026-02-03"
|
||||||
|
# The legacy inspection date is still present, unchanged.
|
||||||
|
assert block["inspection_date"] == "2021-10-07"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ofsted_block_rc_inspection_date_none_when_absent():
|
||||||
|
o = _row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
|
||||||
|
block = _ofsted_block(o, urn=136276)
|
||||||
|
assert block["rc_inspection_date"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_ofsted_block_keeps_existing_keys():
|
def test_ofsted_block_keeps_existing_keys():
|
||||||
block = _ofsted_block(_row(overall_effectiveness=2, quality_of_education=2), urn=1)
|
block = _ofsted_block(_row(overall_effectiveness=2, quality_of_education=2), urn=1)
|
||||||
for key in ("framework", "inspection_date", "overall_effectiveness",
|
for key in ("framework", "inspection_date", "overall_effectiveness",
|
||||||
|
|||||||
@@ -0,0 +1,931 @@
|
|||||||
|
# Compare Screen Must-Fix (Final Expert Review) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fix the five promotion-blocking findings from the expert's final staging review: (1) blank all-secondary compare view, (2) report cards dated with pre-Nov-2025 legacy inspection dates, (3) FSM chip benchmarked against the wrong measure, (4) KS4 "national averages" that are dataset means presented as official DfE figures, (5) factually wrong "DfE didn't publish 2021/22" footnote.
|
||||||
|
|
||||||
|
**Architecture:** One branch/PR touching all three layers. Pipeline: a new tap field carries the report-card inspection's own date; a new EES stream ingests official KS4 national headlines; a new census-benchmarks mart replaces junk KS2-derived context medians. Backend: serialize the new fields, stop mislabelling computed KS4 means as official. Frontend: fix the phase-detection effect that leaves all-secondary comparisons stuck on an empty "primary" tab, date report cards correctly, drop the FSM→disadvantaged fallback, fix the footnote copy.
|
||||||
|
|
||||||
|
**Tech Stack:** Meltano/Singer taps (Python), dbt-postgres, FastAPI/SQLAlchemy/pandas, Next.js app router + Jest, Playwright e2e.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Never push to `main`; work on branch `fix/compare-final-review-mustfix`, open a PR. Never trigger the "Promote to Production (manual)" workflow — promotion is exclusively the human's call.
|
||||||
|
- User-facing behaviour changes must extend the `e2e/` journeys in the same PR (they gate staging fitness and promotability).
|
||||||
|
- All user-facing copy on the compare screen comes verbatim from `docs/superpowers/specs/mockups/compare-desktop.html` / `compare-mobile.html` — except where this plan explicitly changes copy to fix a factual error (Task 6); the spec/mockup gets the same wording in the same commit.
|
||||||
|
- Benchmark provenance house style: official figures = "England average"; computed figures = "state-school average (computed from our dataset)".
|
||||||
|
- A report card must NEVER be displayed with a pre-November-2025 date. Report cards exist only from November 2025.
|
||||||
|
- Backend tests: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q` (repo root; there is no local pytest).
|
||||||
|
- dbt: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` (never bare `dbt` — the Fusion binary shadows dbt-postgres).
|
||||||
|
- Frontend: `cd nextjs-app && npx tsc --noEmit && npm test` (run tsc un-piped so exit codes are not masked).
|
||||||
|
- Do NOT start a local server to test the application (CLAUDE.md).
|
||||||
|
- Commits end with the Claude Code `Co-Authored-By` + `Claude-Session` trailers used on this branch's history.
|
||||||
|
|
||||||
|
## Root-Cause Evidence (verified 2026-07-16, do not re-derive)
|
||||||
|
|
||||||
|
- **Finding 1:** `nextjs-app/components/ComparisonView.tsx:164-176` — the auto-phase effect returns early when `selectedSchools.length === 0` (basket hydrates a beat after mount) and its dep array is only `[comparisonData]`, so it never re-fires; `comparePhase` stays `'primary'`, `activeSchools` is empty, the page renders "No primary schools in your comparison" (a11y snapshot confirmed). No console errors — not a crash.
|
||||||
|
- **Finding 2:** In the Ofsted MI CSV (`Management_information_-_state-funded_schools_-_latest_inspections_as_at_31_May_2026.csv`) the report-card grade columns (cols 38–55, "Safeguarding standards", "Inclusion", …) belong to the **latest full inspection** block whose date is col 30 "Inspection start date" (Barclay 138690: `03/02/2026`). The tap's `inspection_date` COLUMN_PRIORITY matches col 60 "Inspection start date of latest OEIF graded inspection" first (the *legacy* date; NULL for Barclay, so stg coalesces to the 2021 *ungraded* date). The rc data is **real Ofsted data, not fabricated** — it is mis-dated. Also `discover_csv_url()` returns `matches[0]` = the oldest (2017) link on the GOV.UK page; staging works only because `mi_url` is set in the environment. Staging raw is stale for at least Watford Grammar 136276 (staging shows rc grades; the current MI file has all rc columns NULL for it) — a fresh extract fixes that via upsert on `(urn, inspection_date)`.
|
||||||
|
- **Finding 3:** `nextjs-app/components/compare/CompareCommunity.tsx:36` — `bench?.fsm_pct ?? bench?.disadvantaged_pct` falls back across definitions. `benchmarks.primary.fsm_pct` is null because `compute_benchmarks` (backend/data_loader.py:528) medians the *performance* df, which has no `fsm_pct` (school FSM comes from `census.fsm_pct` = `fact_pupil_characteristics`). `disadvantaged_pct` / `eal_pct` are KS2-only columns, so the "secondary" medians (50.0 / 10.0) are computed over the few all-through schools' KS2 rows — junk.
|
||||||
|
- **Finding 4:** `fact_ks4_national_averages.sql` computes unweighted school means (A8 38.94 vs official 46.0; national P8 −0.27, impossible). Official series exists on EES: data-set `1b649e16-01e8-435b-a814-56be2faf9054` ("National characteristics summary data", KS4 performance publication), CSV endpoint same pattern as the KS2 national stream, national level, 2018/19→2024/25, `establishment_type_group = 'All state-funded'`, `breakdown_topic = 'Total'`, `breakdown = 'Total'`. Verified values: 2024/25 A8 46.0, P8 `z` (not published — no KS2 baseline for that cohort), EM 9-5 45.4%, EBacc entry 40.5%. It has **no** `gcse_91_percent` column.
|
||||||
|
- **Finding 5:** `ComparisonChart.tsx:245-246` claims "DfE didn't publish school-level figures for 2021/22". False — DfE published school-level KS2 for 2021/22 in Dec 2022; spec §8.1 itself lists loading it as a pipeline task. The honest claim is that the figures aren't in our dataset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: All-secondary comparison renders (phase-detection fix)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `nextjs-app/components/ComparisonView.tsx:176`
|
||||||
|
- Create: `nextjs-app/__tests__/components/ComparisonView.phase.test.tsx`
|
||||||
|
- Modify: `e2e/tests/journeys.spec.ts` (add helper + journey after the existing `twoPrimaryUrns` helper / primary compare journey)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: existing `ComparisonView` props (`initialData`, `initialUrns`, `metrics`, `selectedMetric`), `ComparisonProvider`.
|
||||||
|
- Produces: no API changes; the auto-phase effect re-runs when the basket hydrates.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing Jest test**
|
||||||
|
|
||||||
|
Create `nextjs-app/__tests__/components/ComparisonView.phase.test.tsx` (mirrors the mock setup of `ComparisonView.refresh.test.tsx`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
/**
|
||||||
|
* Regression: an all-secondary comparison must render the secondary sections.
|
||||||
|
*
|
||||||
|
* The basket hydrates from the URL a beat after mount, so the auto-phase
|
||||||
|
* effect must re-run once selectedSchools arrives — with deps of only
|
||||||
|
* [comparisonData] it fired once against an empty basket, bailed, and the
|
||||||
|
* page stayed on an empty "primary" tab ("No primary schools in your
|
||||||
|
* comparison") even though all schools were secondary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
|
|
||||||
|
const fetchComparison = jest.fn();
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||||
|
|
||||||
|
function secondarySchool(urn: number, name: string): School {
|
||||||
|
return {
|
||||||
|
urn,
|
||||||
|
school_name: name,
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Academy converter',
|
||||||
|
attainment_8_score: 55,
|
||||||
|
phase: 'Secondary',
|
||||||
|
} as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number, name: string): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: secondarySchool(urn, name),
|
||||||
|
yearly_data: [{ year: 202425, attainment_8_score: 55 }] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DATA = {
|
||||||
|
'300': data(300, 'Gamma High'),
|
||||||
|
'400': data(400, 'Delta Academy'),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('an all-secondary comparison renders the sections, not an empty primary tab', async () => {
|
||||||
|
render(
|
||||||
|
<ComparisonProvider>
|
||||||
|
<ComparisonView
|
||||||
|
initialData={INITIAL_DATA}
|
||||||
|
initialNationalAverages={{
|
||||||
|
year: 202425,
|
||||||
|
primary: {},
|
||||||
|
secondary: { attainment_8_score: 46 },
|
||||||
|
by_year: [],
|
||||||
|
}}
|
||||||
|
initialBenchmarks={undefined}
|
||||||
|
initialUrns={[300, 400]}
|
||||||
|
metrics={[]}
|
||||||
|
selectedMetric="attainment_8_score"
|
||||||
|
/>
|
||||||
|
</ComparisonProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
|
||||||
|
expect(fetchComparison).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd nextjs-app && npx jest __tests__/components/ComparisonView.phase.test.tsx`
|
||||||
|
Expected: FAIL — "No primary schools in your comparison" is rendered / "At a glance" never appears.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix the effect dependencies**
|
||||||
|
|
||||||
|
In `nextjs-app/components/ComparisonView.tsx`, the auto-phase effect currently ends:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// selectedSchools is a dep because the basket hydrates after mount: the
|
||||||
|
// first run sees an empty basket and bails, so it must re-fire when the
|
||||||
|
// schools arrive. primarySchools/secondarySchools/metrics/selectedMetric
|
||||||
|
// are intentionally omitted (derived or would cause loops).
|
||||||
|
}, [comparisonData, selectedSchools]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
(`phaseLockedByUser` still suppresses re-detection after a manual tab click; re-running with unchanged inputs sets the same state, which React treats as a no-op.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the new test and the existing suite**
|
||||||
|
|
||||||
|
Run: `cd nextjs-app && npx tsc --noEmit && npm test`
|
||||||
|
Expected: PASS, including `ComparisonView.refresh.test.tsx` (the refresh regression must stay green).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the e2e secondary journey**
|
||||||
|
|
||||||
|
In `e2e/tests/journeys.spec.ts`, add below `twoPrimaryUrns`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
|
||||||
|
const res = await page.request.get('/api/schools?search=school&per_page=100');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const body = await res.json();
|
||||||
|
const urns: string[] = (body.schools ?? [])
|
||||||
|
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
|
||||||
|
s.phase === 'Secondary' && s.attainment_8_score != null,
|
||||||
|
)
|
||||||
|
.map((s: { urn: number }) => String(s.urn));
|
||||||
|
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||||
|
return [urns[0], urns[1]];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(If `/api/schools` list rows lack `attainment_8_score`, filter on `s.phase === 'Secondary'` only — check the response first.) Then add a journey test next to the primary compare journey:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('comparing two secondary schools renders the secondary sections', async ({ page }) => {
|
||||||
|
const [urn0, urn1] = await twoSecondaryUrns(page);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||||
|
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// The parent-first sections must render — this page was completely blank
|
||||||
|
// for all-secondary baskets (expert review must-fix #1).
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Ofsted inspection' })).toBeVisible();
|
||||||
|
// A KS4 measure proves the secondary academics variant rendered.
|
||||||
|
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add nextjs-app/components/ComparisonView.tsx nextjs-app/__tests__/components/ComparisonView.phase.test.tsx e2e/tests/journeys.spec.ts
|
||||||
|
git commit -m "fix(compare): render all-secondary comparisons — re-run phase detection after basket hydration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Report-card inspection date through the pipeline
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py` (COLUMN_PRIORITY, schema, `discover_csv_url`)
|
||||||
|
- Modify: `pipeline/transform/models/staging/stg_ofsted_inspections.sql`
|
||||||
|
- Modify: `pipeline/transform/models/intermediate/int_ofsted_latest.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/fact_ofsted_inspection.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (add column doc if other fact_ofsted columns are documented there)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: MI CSV column `Inspection start date` (the latest **full** inspection = the report-card inspection in the renewed framework; NULL when a school's only inspections are legacy OEIF/ungraded — verified for Watford Grammar).
|
||||||
|
- Produces: `marts.fact_ofsted_inspection.rc_inspection_date` (DATE, null unless the row carries report-card grades). Task 3 depends on this exact column name.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the tap field**
|
||||||
|
|
||||||
|
In `tap.py` COLUMN_PRIORITY, after the `rc_sixth_form` entry, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Date of the latest FULL inspection — in the renewed framework this is
|
||||||
|
# the report-card inspection's own start date (col "Inspection start
|
||||||
|
# date"), distinct from the legacy OEIF graded/ungraded dates above.
|
||||||
|
"rc_inspection_date": ["Inspection start date"],
|
||||||
|
```
|
||||||
|
|
||||||
|
and in the stream schema, next to the other rc properties:
|
||||||
|
|
||||||
|
```python
|
||||||
|
th.Property("rc_inspection_date", th.StringType),
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `inspection_date`'s own priority list also contains `"Inspection start date"` as a lower-priority candidate — that stays; in renewed-framework files the higher-priority OEIF column exists so they map to different columns, and in legacy files both map to the same column but rc grades are absent, and staging nulls `rc_inspection_date` in that case (Step 3).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix `discover_csv_url` to pick the newest file, not `matches[0]`**
|
||||||
|
|
||||||
|
The GOV.UK page lists 2017 files first; `matches[0]` is a 2017 CSV. Replace the body of `discover_csv_url()` to date-sort the `latest_inspections_as_at` links, mirroring `discover_independent_csv_url`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def discover_csv_url() -> str | None:
|
||||||
|
"""Scrape GOV.UK page to find the latest MI CSV download link.
|
||||||
|
|
||||||
|
The page lists a decade of monthly files, oldest first — take the
|
||||||
|
newest 'latest inspections as at <date>' link by parsing its date,
|
||||||
|
never matches[0].
|
||||||
|
"""
|
||||||
|
resp = requests.get(GOV_UK_PAGE, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
csv_links = re.findall(
|
||||||
|
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
months = {
|
||||||
|
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
|
||||||
|
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12,
|
||||||
|
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'jun': 6,
|
||||||
|
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
|
||||||
|
}
|
||||||
|
parsed_links = []
|
||||||
|
for link in csv_links:
|
||||||
|
normalized = link.lower().replace('-', '_')
|
||||||
|
if 'latest_inspections_as_at' not in normalized:
|
||||||
|
continue
|
||||||
|
match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized)
|
||||||
|
if match:
|
||||||
|
day, month_str, year = match.groups()
|
||||||
|
month = months.get(month_str)
|
||||||
|
if month:
|
||||||
|
try:
|
||||||
|
parsed_links.append((datetime(int(year), month, int(day)), link))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
parsed_links.sort(reverse=True)
|
||||||
|
if parsed_links:
|
||||||
|
return parsed_links[0][1]
|
||||||
|
if csv_links:
|
||||||
|
return csv_links[-1]
|
||||||
|
matches = re.findall(
|
||||||
|
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
return matches[0] if matches else None
|
||||||
|
```
|
||||||
|
|
||||||
|
(`mi_url` config still wins when set — `self.config.get("mi_url") or discover_csv_url()` is unchanged.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Parse and guard the date in staging**
|
||||||
|
|
||||||
|
In `stg_ofsted_inspections.sql`, inside the `renamed` CTE after the `rc_sixth_form` line, add:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Start date of the latest FULL inspection (the report-card
|
||||||
|
-- inspection in the renewed framework). Guarded below: only kept
|
||||||
|
-- when the row actually carries report-card grades, because in
|
||||||
|
-- legacy-format files this column is the legacy inspection date.
|
||||||
|
to_date(nullif(trim(rc_inspection_date), 'NULL'), 'DD/MM/YYYY') as rc_inspection_date_raw,
|
||||||
|
```
|
||||||
|
|
||||||
|
and replace the final select:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select
|
||||||
|
*,
|
||||||
|
case
|
||||||
|
when rc_safeguarding_met is not null
|
||||||
|
or rc_inclusion is not null
|
||||||
|
or rc_curriculum_teaching is not null
|
||||||
|
or rc_achievement is not null
|
||||||
|
or rc_attendance_behaviour is not null
|
||||||
|
or rc_personal_development is not null
|
||||||
|
or rc_leadership_governance is not null
|
||||||
|
then rc_inspection_date_raw
|
||||||
|
end as rc_inspection_date
|
||||||
|
from renamed
|
||||||
|
where inspection_date is not null
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Propagate through int + mart**
|
||||||
|
|
||||||
|
Add `rc_inspection_date,` to the explicit column lists of `int_ofsted_latest.sql` and `fact_ofsted_inspection.sql` (after `rc_sixth_form`). Do NOT propagate `rc_inspection_date_raw`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Parse-check dbt**
|
||||||
|
|
||||||
|
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .`
|
||||||
|
Expected: parse OK, no compilation errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/plugins/extractors/tap-uk-ofsted pipeline/transform/models
|
||||||
|
git commit -m "feat(pipeline): carry the report-card inspection's own date; pick newest MI file in discovery"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Report-card date in the API and UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/models.py` (FactOfstedInspection)
|
||||||
|
- Modify: `backend/data_loader.py` (`_ofsted_block`)
|
||||||
|
- Test: `backend/tests/test_supplementary_enrichment.py` (extend the existing `_ofsted_block` tests)
|
||||||
|
- Modify: `nextjs-app/lib/types.ts` (OfstedInspection)
|
||||||
|
- Modify: `nextjs-app/components/compare/CompareOfsted.tsx` ("Inspected" measure)
|
||||||
|
- Test: `nextjs-app/__tests__/components/CompareOfsted.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `marts.fact_ofsted_inspection.rc_inspection_date` (Task 2).
|
||||||
|
- Produces: API `ofsted.rc_inspection_date: string | null` (ISO date). UI rule: report-card displays are dated with `rc_inspection_date` only; when null, show "—" (never the legacy date).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing backend test**
|
||||||
|
|
||||||
|
In `backend/tests/test_supplementary_enrichment.py`, alongside the existing `_ofsted_block` tests, add (reuse the file's existing fake-row helper/style):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_ofsted_block_carries_rc_inspection_date():
|
||||||
|
o = _fake_ofsted_row( # use this file's existing fake/stub construction
|
||||||
|
overall_effectiveness=None,
|
||||||
|
ungraded_grade=2,
|
||||||
|
rc_achievement=1,
|
||||||
|
rc_inspection_date=date(2026, 2, 3),
|
||||||
|
inspection_date=date(2021, 10, 7),
|
||||||
|
)
|
||||||
|
block = _ofsted_block(o, 138690)
|
||||||
|
assert block["rc_inspection_date"] == "2026-02-03"
|
||||||
|
# The legacy inspection date is still present, unchanged.
|
||||||
|
assert block["inspection_date"] == "2021-10-07"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ofsted_block_rc_inspection_date_none_when_absent():
|
||||||
|
o = _fake_ofsted_row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
|
||||||
|
block = _ofsted_block(o, 136276)
|
||||||
|
assert block["rc_inspection_date"] is None
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_supplementary_enrichment.py -q`
|
||||||
|
Expected: FAIL (KeyError / AttributeError on `rc_inspection_date`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Backend implementation**
|
||||||
|
|
||||||
|
`backend/models.py`, in `FactOfstedInspection` after `rc_sixth_form`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Start date of the report-card inspection itself (renewed framework,
|
||||||
|
# Nov 2025+). Null for rows without report-card grades.
|
||||||
|
rc_inspection_date = Column(Date)
|
||||||
|
```
|
||||||
|
|
||||||
|
`backend/data_loader.py` `_ofsted_block`, after the `"inspection_date"` entry:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"rc_inspection_date": (
|
||||||
|
o.rc_inspection_date.isoformat()
|
||||||
|
if getattr(o, "rc_inspection_date", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`getattr` default keeps old test stubs working.) Run the backend suite; expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Failing frontend test**
|
||||||
|
|
||||||
|
`nextjs-app/lib/types.ts`, in `OfstedInspection`, after `inspection_date`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
|
||||||
|
rc_inspection_date?: string | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `nextjs-app/__tests__/components/CompareOfsted.test.tsx`, add to the existing suite (reusing its fixture style):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
||||||
|
const ofsted = reportCardOfsted({
|
||||||
|
inspection_date: '2021-10-07',
|
||||||
|
rc_inspection_date: '2026-02-03',
|
||||||
|
});
|
||||||
|
render(<CompareOfsted schools={[schoolFixture]} data={{ [String(schoolFixture.urn)]: { ...dataFixture, ofsted } }} />);
|
||||||
|
expect(screen.getByText(/3 Feb 2026/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||||
|
expect(screen.queryByText('4+ years ago')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an em dash when a report card has no rc_inspection_date yet', () => {
|
||||||
|
const ofsted = reportCardOfsted({ inspection_date: '2021-10-07', rc_inspection_date: null });
|
||||||
|
render(<CompareOfsted schools={[schoolFixture]} data={{ [String(schoolFixture.urn)]: { ...dataFixture, ofsted } }} />);
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
(`reportCardOfsted` = the file's existing report-card fixture builder, or build inline matching its other tests.) Run just this file; expected: FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Frontend implementation**
|
||||||
|
|
||||||
|
In `CompareOfsted.tsx`, replace the body of the "Inspected" measure's map:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{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;
|
||||||
|
const age = yearsSince(dateIso);
|
||||||
|
return (
|
||||||
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
|
{formatInspectionDate(dateIso)}{' '}
|
||||||
|
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
|
||||||
|
</Cell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run frontend checks**
|
||||||
|
|
||||||
|
Run: `cd nextjs-app && npx tsc --noEmit && npm test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/models.py backend/data_loader.py backend/tests nextjs-app/lib/types.ts nextjs-app/components/compare/CompareOfsted.tsx nextjs-app/__tests__/components/CompareOfsted.test.tsx
|
||||||
|
git commit -m "fix(compare): date report cards with their own inspection date, never the legacy one"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Census-based context benchmarks; kill the FSM fallback
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `pipeline/transform/models/marts/fact_census_benchmarks.sql`
|
||||||
|
- Modify: `pipeline/transform/models/marts/_marts_schema.yml`
|
||||||
|
- Modify: `backend/models.py` (new `CensusBenchmark` model)
|
||||||
|
- Modify: `backend/data_loader.py` (`compute_benchmarks`)
|
||||||
|
- Modify: `backend/app.py` (compare endpoint call site, only if the signature change requires it)
|
||||||
|
- Test: `backend/tests/test_benchmarks.py`
|
||||||
|
- Modify: `nextjs-app/components/compare/CompareCommunity.tsx:36`
|
||||||
|
- Test: `nextjs-app/__tests__/lib/compareLogic.test.ts` or the community section's existing test home (add a fallback-removal test where the FSM chip logic is tested today)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `marts.fact_pupil_characteristics` (urn, year, phase_type_grouping, total_pupils, fsm_pct, eal_pct).
|
||||||
|
- Produces: `marts.fact_census_benchmarks` — one row per phase (`'primary'`/`'secondary'`), columns `phase, year, fsm_pct, eal_pct, median_pupils`. `fsm_pct`/`eal_pct` are **pupil-weighted means** (so they approximate the national pupil-level rate, answering the expert's objection to school-median anchors). API `benchmarks.{primary,secondary}` keeps its existing keys; `fsm_pct`/`eal_pct`/`median_pupils` now come from this mart; `disadvantaged_pct` becomes primary-only (the KS2-column median was junk for secondary).
|
||||||
|
|
||||||
|
- [ ] **Step 1: dbt mart**
|
||||||
|
|
||||||
|
Create `pipeline/transform/models/marts/fact_census_benchmarks.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Mart: state-school context benchmarks from the pupil census — one row per
|
||||||
|
-- phase, latest census year. Computed at import time (never per request).
|
||||||
|
-- fsm_pct / eal_pct are pupil-weighted means, i.e. "what % of pupils", not
|
||||||
|
-- "the median school" — this matches how DfE quotes national FSM/EAL rates.
|
||||||
|
-- Consumers must label these "state-school average (computed from our
|
||||||
|
-- dataset)" (spec §8.6), never "England average".
|
||||||
|
|
||||||
|
with latest as (
|
||||||
|
select max(year) as year from {{ ref('fact_pupil_characteristics') }}
|
||||||
|
),
|
||||||
|
|
||||||
|
classified as (
|
||||||
|
select
|
||||||
|
case
|
||||||
|
when p.phase_type_grouping ilike '%primary%' then 'primary'
|
||||||
|
when p.phase_type_grouping ilike '%secondary%' then 'secondary'
|
||||||
|
end as phase,
|
||||||
|
p.total_pupils,
|
||||||
|
p.fsm_pct,
|
||||||
|
p.eal_pct,
|
||||||
|
l.year
|
||||||
|
from {{ ref('fact_pupil_characteristics') }} p
|
||||||
|
join latest l on p.year = l.year
|
||||||
|
where p.total_pupils is not null and p.total_pupils > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
select
|
||||||
|
phase,
|
||||||
|
max(year) as year,
|
||||||
|
round((sum(fsm_pct * total_pupils) filter (where fsm_pct is not null)
|
||||||
|
/ nullif(sum(total_pupils) filter (where fsm_pct is not null), 0))::numeric, 1) as fsm_pct,
|
||||||
|
round((sum(eal_pct * total_pupils) filter (where eal_pct is not null)
|
||||||
|
/ nullif(sum(total_pupils) filter (where eal_pct is not null), 0))::numeric, 1) as eal_pct,
|
||||||
|
round(percentile_cont(0.5) within group (order by total_pupils))::integer as median_pupils
|
||||||
|
from classified
|
||||||
|
where phase is not null
|
||||||
|
group by phase
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `fact_census_benchmarks` entry to `_marts_schema.yml` in the file's existing style (name + description; column tests only if sibling marts have them).
|
||||||
|
|
||||||
|
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Failing backend test**
|
||||||
|
|
||||||
|
In `backend/tests/test_benchmarks.py` add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_benchmarks_use_census_mart_for_context(monkeypatch):
|
||||||
|
census = {
|
||||||
|
"primary": {"year": 202425, "fsm_pct": 25.3, "eal_pct": 21.8, "median_pupils": 240},
|
||||||
|
"secondary": {"year": 202425, "fsm_pct": 24.1, "eal_pct": 18.9, "median_pupils": 980},
|
||||||
|
}
|
||||||
|
result = compute_benchmarks(_sample_df(), census_benchmarks=census)
|
||||||
|
assert result["primary"]["fsm_pct"] == 25.3
|
||||||
|
assert result["secondary"]["eal_pct"] == 18.9
|
||||||
|
assert result["secondary"]["median_pupils"] == 980
|
||||||
|
# KS2-only columns must not produce a fake secondary disadvantaged anchor.
|
||||||
|
assert result["secondary"]["disadvantaged_pct"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmarks_context_none_when_mart_missing():
|
||||||
|
result = compute_benchmarks(_sample_df(), census_benchmarks=None)
|
||||||
|
assert result["primary"]["fsm_pct"] is None # never silently fall back
|
||||||
|
```
|
||||||
|
|
||||||
|
(`_sample_df()` = this file's existing dataframe fixture.) Run the file; expected: FAIL (unexpected keyword `census_benchmarks`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Backend implementation**
|
||||||
|
|
||||||
|
`backend/models.py` (next to the national-average models):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CensusBenchmark(Base):
|
||||||
|
"""State-school context benchmarks from the pupil census — one row per phase."""
|
||||||
|
__tablename__ = "fact_census_benchmarks"
|
||||||
|
__table_args__ = MARTS
|
||||||
|
|
||||||
|
phase = Column(String(20), primary_key=True)
|
||||||
|
year = Column(Integer)
|
||||||
|
fsm_pct = Column(Float) # pupil-weighted mean
|
||||||
|
eal_pct = Column(Float) # pupil-weighted mean
|
||||||
|
median_pupils = Column(Integer)
|
||||||
|
```
|
||||||
|
|
||||||
|
`backend/data_loader.py` — change the signature and `_block`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None) -> dict:
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside, keep `_median` and `_weighted_disadvantaged` as-is, and replace `_block` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _block(sub, phase, with_disadvantaged):
|
||||||
|
census = (census_benchmarks or {}).get(phase) or {}
|
||||||
|
block = {
|
||||||
|
# Context measures come from the census mart (pupil-weighted):
|
||||||
|
# the performance df has no fsm_pct, and its eal/disadvantaged
|
||||||
|
# columns are KS2-only — medianing them for "secondary" produced
|
||||||
|
# junk anchors from the handful of all-through schools.
|
||||||
|
"eal_pct": census.get("eal_pct"),
|
||||||
|
"sen_support_pct": _median(sub, "sen_support_pct"),
|
||||||
|
"disadvantaged_pct": _median(sub, "disadvantaged_pct") if with_disadvantaged else None,
|
||||||
|
"fsm_pct": census.get("fsm_pct"),
|
||||||
|
"median_pupils": census.get("median_pupils"),
|
||||||
|
}
|
||||||
|
if with_disadvantaged:
|
||||||
|
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
|
||||||
|
return block
|
||||||
|
```
|
||||||
|
|
||||||
|
and the return:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return {
|
||||||
|
"source": "state-school average (computed from our dataset)",
|
||||||
|
"year": int(latest_year),
|
||||||
|
"primary": _block(prim, "primary", with_disadvantaged=True),
|
||||||
|
"secondary": _block(sec, "secondary", with_disadvantaged=False),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `backend/app.py`'s compare endpoint, load the mart and pass it (same defensive style as the national-averages queries):
|
||||||
|
|
||||||
|
```python
|
||||||
|
census_benchmarks = None
|
||||||
|
try:
|
||||||
|
rows = db.query(CensusBenchmark).all()
|
||||||
|
if rows:
|
||||||
|
census_benchmarks = {
|
||||||
|
r.phase: {
|
||||||
|
"year": r.year,
|
||||||
|
"fsm_pct": r.fsm_pct,
|
||||||
|
"eal_pct": r.eal_pct,
|
||||||
|
"median_pupils": r.median_pupils,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
...
|
||||||
|
"benchmarks": compute_benchmarks(df, census_benchmarks=census_benchmarks),
|
||||||
|
```
|
||||||
|
|
||||||
|
(Import `CensusBenchmark`; use the endpoint's existing db session pattern.) Run the backend suite; expected: PASS (update any existing benchmark tests that asserted the old median-sourced fsm/eal values).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Frontend — remove the cross-definition fallback**
|
||||||
|
|
||||||
|
`nextjs-app/components/compare/CompareCommunity.tsx:36`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const anchor = bench?.fsm_pct ?? null;
|
||||||
|
```
|
||||||
|
|
||||||
|
If the FSM chip has unit coverage, update/add the case: `anchor` null ⇒ no verdict chip rendered (bare value only). Run `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/transform/models/marts backend/models.py backend/data_loader.py backend/app.py backend/tests/test_benchmarks.py nextjs-app/components/compare/CompareCommunity.tsx nextjs-app/__tests__
|
||||||
|
git commit -m "fix(compare): census-sourced FSM/EAL benchmarks; never fall back across measure definitions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Official KS4 national averages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py` (new stream, registered in `discover_streams`)
|
||||||
|
- Create: `pipeline/transform/models/staging/stg_ees_ks4_national.sql`
|
||||||
|
- Modify: `pipeline/transform/models/staging/_stg_sources.yml` (add raw table `ees_ks4_national`)
|
||||||
|
- Modify: `pipeline/transform/models/marts/fact_ks4_national_averages.sql` (rewrite)
|
||||||
|
- Modify: `backend/models.py` (Ks4NationalAverage docstring), `backend/app.py` (`_national_averages_payload` — remove the computed fallback)
|
||||||
|
- Test: `backend/tests/test_national_averages_marts.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: EES data-catalogue CSV `https://explore-education-statistics.service.gov.uk/data-catalogue/data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv` (columns verified: `time_period, geographic_level, establishment_type_group, breakdown_topic, breakdown, attainment8_average, progress8_average, engmath_95_percent, engmath_94_percent, ebacc_entering_percent, ebacc_95_percent, ebacc_94_percent, ebacc_aps_average, …`).
|
||||||
|
- Produces: `marts.fact_ks4_national_averages` with the SAME columns as today (so `Ks4NationalAverage` needs no schema change), now holding official DfE figures; `gcse_grade_91_pct` is NULL (not in the official series — the England anchor for that measure disappears, which is correct: it was noise).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tap stream**
|
||||||
|
|
||||||
|
In `tap.py`, after the KS2 national stream, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── KS4 National Headlines (national level only — one row per year) ──────────
|
||||||
|
# Dataset: "National characteristics summary data" (Key stage 4 performance).
|
||||||
|
# Official England state-funded headline measures, 2018/19 → latest.
|
||||||
|
# Suppressed values ('z', 'x') → NULL downstream. Progress 8 is legitimately
|
||||||
|
# absent in years with no KS2 baseline (e.g. 2024/25) — that is DfE policy,
|
||||||
|
# not missing data.
|
||||||
|
|
||||||
|
_KS4_NATIONAL_CSV_URL = (
|
||||||
|
"https://explore-education-statistics.service.gov.uk/data-catalogue/"
|
||||||
|
"data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
_KS4_NATIONAL_COL_MAP = {
|
||||||
|
"attainment8_average": "attainment_8_score",
|
||||||
|
"progress8_average": "progress_8_score",
|
||||||
|
"engmath_94_percent": "english_maths_standard_pass_pct",
|
||||||
|
"engmath_95_percent": "english_maths_strong_pass_pct",
|
||||||
|
"ebacc_entering_percent": "ebacc_entry_pct",
|
||||||
|
"ebacc_94_percent": "ebacc_standard_pass_pct",
|
||||||
|
"ebacc_95_percent": "ebacc_strong_pass_pct",
|
||||||
|
"ebacc_aps_average": "ebacc_avg_score",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EESKs4NationalStream(Stream):
|
||||||
|
"""National KS4 headline averages — one row per academic year.
|
||||||
|
|
||||||
|
Filters to geographic_level == 'National', establishment_type_group ==
|
||||||
|
'All state-funded', breakdown_topic == 'Total', breakdown == 'Total'
|
||||||
|
so only the England-wide all-pupils row per year is emitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "ees_ks4_national"
|
||||||
|
primary_keys = ["time_period"]
|
||||||
|
replication_key = None
|
||||||
|
|
||||||
|
schema = th.PropertiesList(
|
||||||
|
th.Property("time_period", th.StringType, required=True),
|
||||||
|
*[th.Property(out, th.StringType) for out in _KS4_NATIONAL_COL_MAP.values()],
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
def get_records(self, context):
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
self.logger.info("Downloading KS4 national headlines: %s", _KS4_NATIONAL_CSV_URL)
|
||||||
|
resp = requests.get(_KS4_NATIONAL_CSV_URL, timeout=60)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
|
||||||
|
df.columns = [c.strip().lower() for c in df.columns]
|
||||||
|
|
||||||
|
for col, want in [
|
||||||
|
("geographic_level", "national"),
|
||||||
|
("establishment_type_group", "all state-funded"),
|
||||||
|
("breakdown_topic", "total"),
|
||||||
|
("breakdown", "total"),
|
||||||
|
]:
|
||||||
|
if col in df.columns:
|
||||||
|
df = df[df[col].str.strip().str.lower() == want]
|
||||||
|
|
||||||
|
self.logger.info("Emitting %d national KS4 rows", len(df))
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
record = {"time_period": row.get("time_period", "").strip()}
|
||||||
|
for src, out in _KS4_NATIONAL_COL_MAP.items():
|
||||||
|
record[out] = row.get(src, "")
|
||||||
|
yield record
|
||||||
|
```
|
||||||
|
|
||||||
|
Register `EESKs4NationalStream(self)` in `discover_streams` next to the KS2 national stream.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Raw source + staging model**
|
||||||
|
|
||||||
|
Add to `_stg_sources.yml` under the raw source, matching the `ees_ks2_national` entry's style:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: ees_ks4_national
|
||||||
|
description: Official DfE KS4 national headline averages (EES data catalogue)
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `pipeline/transform/models/staging/stg_ees_ks4_national.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Staging model: official DfE KS4 national headline averages — one row per
|
||||||
|
-- academic year (England, all state-funded, all pupils). Source: EES data
|
||||||
|
-- catalogue "National characteristics summary data". Suppressed values
|
||||||
|
-- ('z', 'x') are coerced to NULL by safe_numeric — Progress 8 is 'z' in
|
||||||
|
-- years with no KS2 baseline (e.g. 2024/25): legitimately unpublished.
|
||||||
|
|
||||||
|
select
|
||||||
|
cast(trim(time_period) as integer) as year,
|
||||||
|
{{ safe_numeric('attainment_8_score') }} as attainment_8_score,
|
||||||
|
{{ safe_numeric('progress_8_score') }} as progress_8_score,
|
||||||
|
{{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
|
||||||
|
{{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
|
||||||
|
{{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_avg_score') }} as ebacc_avg_score
|
||||||
|
from {{ source('raw', 'ees_ks4_national') }}
|
||||||
|
where time_period ~ '^[0-9]+$'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite the mart**
|
||||||
|
|
||||||
|
Replace the entire body of `fact_ks4_national_averages.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Mart: OFFICIAL DfE KS4 national headline averages — one row per academic
|
||||||
|
-- year (England, state-funded, all pupils), from the EES national dataset.
|
||||||
|
-- Replaces the previous unweighted school-level means, which were 7–15
|
||||||
|
-- points off every headline measure and produced an arithmetically
|
||||||
|
-- impossible national Progress 8. gcse_grade_91_pct has no official
|
||||||
|
-- national series and is NULL (schema kept for the API model).
|
||||||
|
|
||||||
|
select
|
||||||
|
year,
|
||||||
|
attainment_8_score,
|
||||||
|
progress_8_score,
|
||||||
|
english_maths_standard_pass_pct,
|
||||||
|
english_maths_strong_pass_pct,
|
||||||
|
ebacc_entry_pct,
|
||||||
|
ebacc_standard_pass_pct,
|
||||||
|
ebacc_strong_pass_pct,
|
||||||
|
ebacc_avg_score,
|
||||||
|
cast(null as double precision) as gcse_grade_91_pct
|
||||||
|
from {{ ref('stg_ees_ks4_national') }}
|
||||||
|
order by year
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Backend — official provenance, no computed fallback**
|
||||||
|
|
||||||
|
`backend/models.py`: change the `Ks4NationalAverage` docstring to `"""Official DfE KS4 national headline averages — one row per academic year."""`.
|
||||||
|
|
||||||
|
`backend/app.py` `_national_averages_payload`: delete the entire `if not any(secondary_by_year.values()):` fallback block (it computes dataset means that the UI footnote then labels official). Update the function docstring's KS4 sentence to: `official DfE KS4 figures (fact_ks4_national_averages). If the KS4 mart hasn't been built yet, the secondary series is empty — never a computed stand-in, because the UI labels these figures as official.`
|
||||||
|
|
||||||
|
Update `backend/tests/test_national_averages_marts.py`: the test that exercised the fallback now asserts the opposite —
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_ks4_secondary_empty_when_mart_missing(...):
|
||||||
|
# No computed stand-in: the UI labels national figures as official DfE
|
||||||
|
# data, so an empty mart must yield an empty secondary series.
|
||||||
|
payload = _national_averages_payload(df)
|
||||||
|
assert all(not e["secondary"] for e in payload["by_year"])
|
||||||
|
```
|
||||||
|
|
||||||
|
(adapt to the file's existing fixtures/monkeypatching). Run the backend suite — expected PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pipeline/plugins/extractors/tap-uk-ees pipeline/transform backend
|
||||||
|
git commit -m "fix(data): official DfE KS4 national headline averages; drop mislabelled computed means"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Honest 2021/22 footnote
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `nextjs-app/components/ComparisonChart.tsx:243-247`
|
||||||
|
- Modify: `nextjs-app/lib/compareChartData.ts` (comment lines 7, 53–55 — comments only, no logic)
|
||||||
|
- Modify: `nextjs-app/__tests__/lib/compareChartData.test.ts` (test name/comment wording only)
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md` §8.1
|
||||||
|
|
||||||
|
**Interfaces:** none — copy and docs only. This is the one place the plan changes reviewed copy, because the reviewed copy is factually wrong (Global Constraints exception).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fix the user-facing copy**
|
||||||
|
|
||||||
|
In `ComparisonChart.tsx` replace the note:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{built.showUnpublished202122Note && (
|
||||||
|
<p className={styles.chartNote}>
|
||||||
|
No national tests were held in 2019/20 and 2020/21 (COVID), and our dataset doesn't
|
||||||
|
yet include school-level figures for 2021/22 — the England average is shown for that
|
||||||
|
year.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix the lying comments**
|
||||||
|
|
||||||
|
In `compareChartData.ts`, update the header comment (line 7) and the `showUnpublished202122Note` doc comment (lines 53–55) to say the 2021/22 school-level figures are *absent from our dataset* (DfE published them in Dec 2022; ingesting them is a backlog pipeline task), not "unpublished". Rename nothing (the flag name stays — pure rename churn). In `compareChartData.test.ts`, adjust the test description/comment wording the same way.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Correct spec §8.1**
|
||||||
|
|
||||||
|
In the spec's §8.1, replace any wording that calls 2021/22 school-level KS2 a "permanent DfE gap" with: DfE published school-level KS2 results for 2021/22 in December 2022 (with comparability caveats); they are not yet ingested — loading them remains an open pipeline task, and the chart footnote says "our dataset doesn't yet include" accordingly.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify + commit**
|
||||||
|
|
||||||
|
Run: `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add nextjs-app docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md
|
||||||
|
git commit -m "fix(compare): stop attributing the missing 2021/22 school-level year to DfE"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Full verification, PR, and post-deploy checklist
|
||||||
|
|
||||||
|
**Files:** none new (verification + PR).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run everything**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q
|
||||||
|
cd nextjs-app && npx tsc --noEmit && npm test && cd ..
|
||||||
|
cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir . && cd ../..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open the PR**
|
||||||
|
|
||||||
|
Push `fix/compare-final-review-mustfix`; open a PR via the Gitea API using `git credential fill` basic auth (token-header auth 401s). PR body: summarize the five findings and fixes, link the expert review, end with the standard Claude Code attribution + session URL. Note in the body that findings 2 and 4 also need a **DAG run after the staging deploy** before the UI shows corrected data.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Post-merge staging verification (after the user merges and the daily DAG runs — record results, do not promote)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Report card dated by its own inspection (Barclay): expect 2026-02-03
|
||||||
|
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690" | python3 -c "import json,sys; o=json.load(sys.stdin)['comparison']['138690']['ofsted']; print(o['rc_inspection_date'], o['inspection_date'])"
|
||||||
|
# Stale Watford rc grades cleared by the fresh extract: expect report_card == {}
|
||||||
|
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['comparison']['136276']['ofsted']['report_card'])"
|
||||||
|
# Official KS4 nationals: expect A8 46.0 for 202425, progress_8_score absent
|
||||||
|
curl -sk "https://stx.schoolcompare.co.uk/api/national-averages" | python3 -c "import json,sys; print(json.load(sys.stdin)['secondary'])"
|
||||||
|
# FSM benchmark real (~24-26), secondary disadvantaged_pct gone
|
||||||
|
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690,136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['benchmarks'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-screenshot both phase views (desktop + mobile, "More measures" expanded, Watford Grammar in the secondary set) and hand them to the Ofsted expert agent for the sign-off pass it said it expects. Production promotion remains the human's manual call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (expert should-fix/minor — separate follow-ups)
|
||||||
|
|
||||||
|
- 137086-style interim state (subgrades without an overall from an RI reinspection) rendering treatment (finding 6).
|
||||||
|
- Disadvantaged cohort sizes on the attainment row (finding 7, spec §8.5).
|
||||||
|
- SEN/EAL "typical school" labelling and secondary SEN benchmark (finding 8) — note Task 4 already upgrades EAL to a pupil-weighted census figure.
|
||||||
|
- Selective-school admissions copy variant (finding 9).
|
||||||
|
- Removing/relabelling `gcse_grade_91_pct` as a compare measure (finding 10) — Task 5 already removes its false England anchor.
|
||||||
|
- Palette deviation (11), trends picker label (12), "More measures" expanded-state verification (13).
|
||||||
|
- Actually ingesting the 2021/22 school-level KS2 release (the copy in Task 6 says "doesn't *yet* include").
|
||||||
@@ -256,7 +256,12 @@ implementation, beyond what the mockups can show:
|
|||||||
DfE stated it would not publish KS2 2021/22 in performance tables
|
DfE stated it would not publish KS2 2021/22 in performance tables
|
||||||
(verified 2026-07-12 against EES, the CSP download service, and
|
(verified 2026-07-12 against EES, the CSP download service, and
|
||||||
DfE release notes; see `# TASK 6 VERIFICATION` in
|
DfE release notes; see `# TASK 6 VERIFICATION` in
|
||||||
`pipeline/scripts/diagnose_compare_gaps.py`). The chart's England-
|
`pipeline/scripts/diagnose_compare_gaps.py`; re-verified 2026-07-16
|
||||||
|
after an expert-review challenge — the GOV.UK statistics announcement
|
||||||
|
"Primary school performance tables: 2022" is marked CANCELLED with
|
||||||
|
"will not be published in key stage 2 performance tables in academic
|
||||||
|
year 2021/22", so the footnote's "DfE didn't publish" claim stands
|
||||||
|
and must not be softened to "not in our dataset"). The chart's England-
|
||||||
only 2021/22 point with broken school lines is therefore the
|
only 2021/22 point with broken school lines is therefore the
|
||||||
correct permanent rendering; copy should say "DfE didn't publish
|
correct permanent rendering; copy should say "DfE didn't publish
|
||||||
school-level figures for 2021/22", not "not in our dataset yet".
|
school-level figures for 2021/22", not "not in our dataset yet".
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
|
|||||||
return [urns[0], urns[1]];
|
return [urns[0], urns[1]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
|
||||||
|
const res = await page.request.get('/api/schools?search=school&per_page=100');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const body = await res.json();
|
||||||
|
const urns: string[] = (body.schools ?? [])
|
||||||
|
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
|
||||||
|
s.phase === 'Secondary' && s.attainment_8_score != null,
|
||||||
|
)
|
||||||
|
.map((s: { urn: number }) => String(s.urn));
|
||||||
|
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||||
|
return [urns[0], urns[1]];
|
||||||
|
}
|
||||||
|
|
||||||
test('home page loads with hero search', async ({ page }) => {
|
test('home page loads with hero search', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.locator('h1').first()).toBeVisible();
|
await expect(page.locator('h1').first()).toBeVisible();
|
||||||
@@ -67,6 +80,33 @@ test('searching by postcode returns nearby schools', async ({ page }) => {
|
|||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a report-card school shows a Report Card badge in search results, not its old grade', async ({ page }) => {
|
||||||
|
// List/map badges keyed off ofsted_grade (the carried-forward legacy grade)
|
||||||
|
// and never reached the report-card branch, so report-card schools were
|
||||||
|
// labelled by their old grade (e.g. "Outstanding · 2021"). The list now
|
||||||
|
// carries ofsted_rc_date and the badge treats a report card as winning.
|
||||||
|
const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card
|
||||||
|
const res = await page.request.get(`/api/schools?search=Barclay%20Primary&page_size=5`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const barclay = ((await res.json()).schools ?? []).find(
|
||||||
|
(s: { urn: number }) => s.urn === RC_URN,
|
||||||
|
);
|
||||||
|
// Hard assertions, not test.skip: if the backend stops exposing
|
||||||
|
// ofsted_rc_date for this report-card school, that IS the regression this
|
||||||
|
// test exists to catch, so it must fail loudly rather than skip.
|
||||||
|
expect(barclay, 'Barclay must appear in the search results').toBeTruthy();
|
||||||
|
expect(
|
||||||
|
barclay.ofsted_rc_date,
|
||||||
|
'the list must expose ofsted_rc_date for a report-card school',
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
await searchByName(page, 'Barclay Primary');
|
||||||
|
// The Barclay row must be present…
|
||||||
|
await expect(page.locator(`a[href*="${RC_URN}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
// …badged as a Report Card, not its carried-forward "Outstanding" grade.
|
||||||
|
await expect(page.getByText(/Report Card ·/).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('school detail page renders name and performance data', async ({ page }) => {
|
test('school detail page renders name and performance data', async ({ page }) => {
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
const firstSchool = schoolLinks(page).first();
|
const firstSchool = schoolLinks(page).first();
|
||||||
@@ -81,6 +121,86 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
|||||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => {
|
||||||
|
// Detail views detected report cards via `framework`, which the API never
|
||||||
|
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
|
||||||
|
// dated to a pre-Nov-2025 inspection. Detection now keys off the report_card
|
||||||
|
// object and dates it with rc_inspection_date.
|
||||||
|
const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card
|
||||||
|
const res = await page.request.get(`/api/schools/${RC_URN}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const ofsted = (await res.json()).ofsted;
|
||||||
|
test.skip(
|
||||||
|
!ofsted?.report_card || Object.keys(ofsted.report_card).length === 0,
|
||||||
|
'precondition: chosen URN must currently have a report card',
|
||||||
|
);
|
||||||
|
const rcYear = new Date(ofsted.rc_inspection_date).getFullYear();
|
||||||
|
const legacyYear = new Date(ofsted.inspection_date).getFullYear();
|
||||||
|
|
||||||
|
await page.goto(`/school/${RC_URN}`);
|
||||||
|
const ofstedSection = page.locator('#ofsted');
|
||||||
|
// Detection fixed: rendered as a Report Card, not a legacy "Ofsted Rating".
|
||||||
|
await expect(ofstedSection.getByText('Ofsted Report Card')).toBeVisible({ timeout: 15_000 });
|
||||||
|
// Dating fixed: dated to the report-card inspection, never the legacy one.
|
||||||
|
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${rcYear}`))).toBeVisible();
|
||||||
|
if (legacyYear !== rcYear) {
|
||||||
|
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${legacyYear}`))).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not just one phase', async ({ page }) => {
|
||||||
|
// All-through schools carry both KS2 and KS4 data in the same yearly rows.
|
||||||
|
// The detail view used to flip them to isSecondary and render GCSE-only,
|
||||||
|
// hiding the primary phase. It now renders both phases and labels the school
|
||||||
|
// "All-through".
|
||||||
|
const AT_URN = 137306; // Hessle High School and Penshurst Primary — all-through
|
||||||
|
const res = await page.request.get(`/api/schools/${AT_URN}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const detail = await res.json();
|
||||||
|
const rows: Array<{ rwm_expected_pct: number | null; attainment_8_score: number | null }> =
|
||||||
|
detail.yearly_data ?? [];
|
||||||
|
const hasKS2 = rows.some((r) => r.rwm_expected_pct != null);
|
||||||
|
const hasKS4 = rows.some((r) => r.attainment_8_score != null);
|
||||||
|
test.skip(
|
||||||
|
(detail.school_info?.phase ?? '').toLowerCase() !== 'all-through' || !hasKS2 || !hasKS4,
|
||||||
|
'precondition: chosen URN must currently be all-through with both KS2 and KS4 results',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/school/${AT_URN}`);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Labelled as all-through in the hero meta.
|
||||||
|
await expect(page.getByText(/All-through/i).first()).toBeVisible();
|
||||||
|
|
||||||
|
// The combined results section carries both phases.
|
||||||
|
const results = page.locator('#results');
|
||||||
|
await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible();
|
||||||
|
await expect(results.getByRole('heading', { name: /Primary.*KS2 SATs/ })).toBeVisible(); // KS2 block
|
||||||
|
await expect(results.getByRole('heading', { name: /Secondary.*GCSEs/ })).toBeVisible(); // KS4 block
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a special school is not shown as failing against the mainstream England average', async ({ page }) => {
|
||||||
|
// Special schools sit the same tests but very few pupils reach the mainstream
|
||||||
|
// "expected standard", so a "0.0% · −62 pts below England average" rendering
|
||||||
|
// portrays them as failing against a benchmark that doesn't fit. The results
|
||||||
|
// section drops the England comparison and explains the context instead.
|
||||||
|
const SP_URN = 101099; // Greenmead School — a community special school
|
||||||
|
const res = await page.request.get(`/api/schools/${SP_URN}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const detail = await res.json();
|
||||||
|
test.skip(
|
||||||
|
!/special|pupil referral|alternative provision/i.test(detail.school_info?.school_type ?? ''),
|
||||||
|
'precondition: chosen URN must currently be a special school',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/school/${SP_URN}`);
|
||||||
|
const results = page.locator('#results');
|
||||||
|
// The special-school context note is shown…
|
||||||
|
await expect(results.getByText(/This is a special school/i)).toBeVisible({ timeout: 15_000 });
|
||||||
|
// …and the mainstream England-average comparison is dropped entirely.
|
||||||
|
await expect(results.getByText(/England avg/i)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
||||||
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
||||||
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
||||||
@@ -184,6 +304,16 @@ test('comparing two schools shows the parent-first sections side by side', async
|
|||||||
// Every number gets an anchor: at least one England-average tick or label
|
// Every number gets an anchor: at least one England-average tick or label
|
||||||
await expect(page.getByText(/England \d+/).first()).toBeVisible();
|
await expect(page.getByText(/England \d+/).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Desktop: the sticky school bar shares the sections' grid template
|
||||||
|
// (200px label rail + one column per school) so chips align with the
|
||||||
|
// columns they label.
|
||||||
|
const barTemplate = await page
|
||||||
|
.locator('[aria-label="Schools in this comparison"]')
|
||||||
|
.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
|
||||||
|
expect(barTemplate).toMatch(/^200px /);
|
||||||
|
// ...and its label rail carries the comparison caption.
|
||||||
|
await expect(page.getByText(/^\d+ (primary|secondary) schools?$/)).toBeVisible();
|
||||||
|
|
||||||
// Ofsted linkout goes to the school's provider page, never a report deep-link
|
// Ofsted linkout goes to the school's provider page, never a report deep-link
|
||||||
const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first();
|
const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first();
|
||||||
await expect(ofstedLink).toBeVisible();
|
await expect(ofstedLink).toBeVisible();
|
||||||
@@ -199,6 +329,53 @@ test('comparing two schools shows the parent-first sections side by side', async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('comparing two secondary schools renders the secondary sections', async ({ page }) => {
|
||||||
|
const [urn0, urn1] = await twoSecondaryUrns(page);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||||
|
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// The parent-first sections must render — this page was completely blank
|
||||||
|
// for all-secondary baskets (expert review must-fix #1).
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByRole('heading', { name: 'Ofsted inspection' }).first()).toBeVisible();
|
||||||
|
// A KS4 measure proves the secondary academics variant rendered.
|
||||||
|
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
||||||
|
|
||||||
|
// The admissions template must be phase-aware: the primaries' distance
|
||||||
|
// copy ("non-faith primaries") must never appear on a secondary comparison
|
||||||
|
// (expert sign-off must-fix M3).
|
||||||
|
await expect(page.getByText(/non-faith primaries/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
|
||||||
|
// Regression: the first visit stores a basket in localStorage; opening a
|
||||||
|
// link for a DIFFERENT school set then raced a stale fetch for the stored
|
||||||
|
// basket against the new SSR data, blanking every section (including the
|
||||||
|
// trends chart) until a hard refresh.
|
||||||
|
const [s0, s1] = await twoSecondaryUrns(page);
|
||||||
|
const [p0, p1] = await twoPrimaryUrns(page);
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${s0},${s1}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/compare?urns=${p0},${p1}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
// Give any straggling stale response time to land, then confirm the new
|
||||||
|
// comparison is still on screen.
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Explore trends' }).first()).toBeVisible();
|
||||||
|
await expect(page.locator(`a[href*="${p0}"]`).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
|
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
|
||||||
@@ -261,6 +438,18 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
|
|||||||
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
|
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('admissions guide renders its key milestones', async ({ page }) => {
|
||||||
|
// Static content page — assert the guide loads and the load-bearing
|
||||||
|
// milestones parents rely on are present (dates are statutory, so these
|
||||||
|
// strings are stable invariants, not data-refresh-sensitive).
|
||||||
|
await page.goto('/admissions');
|
||||||
|
await expect(page.getByRole('heading', { name: /School Admissions Guide/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: /Primary school admissions/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: /Secondary school admissions/i })).toBeVisible();
|
||||||
|
// National Offer Day is the milestone the whole guide builds toward.
|
||||||
|
await expect(page.getByText(/National Offer Day/i).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('rankings page loads a populated table', async ({ page }) => {
|
test('rankings page loads a populated table', async ({ page }) => {
|
||||||
await page.goto('/rankings');
|
await page.goto('/rankings');
|
||||||
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
|
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Secondary academics: every headline number carries its England anchor and a
|
||||||
|
* verdict chip (expert sign-off SF1 — the grade-5 and EBacc rows previously
|
||||||
|
* rendered as bare numbers, breaking the "anchored against England" promise).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { CompareAcademics } from '@/components/compare/CompareAcademics';
|
||||||
|
import type { ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||||
|
|
||||||
|
function school(urn: number, name: string): School {
|
||||||
|
return { urn, school_name: name, attainment_8_score: 58.7 } as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: school(urn, 'Test High'),
|
||||||
|
yearly_data: [
|
||||||
|
{
|
||||||
|
year: 202425,
|
||||||
|
attainment_8_score: 58.7,
|
||||||
|
english_maths_strong_pass_pct: 30,
|
||||||
|
ebacc_entry_pct: 10,
|
||||||
|
},
|
||||||
|
] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NATIONAL: NationalAverages = {
|
||||||
|
year: 202425,
|
||||||
|
primary: {},
|
||||||
|
secondary: {
|
||||||
|
attainment_8_score: 46.0,
|
||||||
|
english_maths_strong_pass_pct: 45.4,
|
||||||
|
ebacc_entry_pct: 40.5,
|
||||||
|
},
|
||||||
|
by_year: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('grade-5 and EBacc rows show the England anchor and a Below chip when under it', () => {
|
||||||
|
const s = school(137086, 'Bishop Stopford School');
|
||||||
|
render(
|
||||||
|
<CompareAcademics
|
||||||
|
schools={[s]}
|
||||||
|
data={{ '137086': data(137086) }}
|
||||||
|
nationalAverages={NATIONAL}
|
||||||
|
isSecondary
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The official anchors appear (45.4% and 40.5%), not just the school numbers.
|
||||||
|
expect(screen.getByText(/England average 45%/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/England average 41%/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 30% grade-5 and 10% EBacc are both well below their anchors → Below chips.
|
||||||
|
// Attainment 8 (58.7 vs 46.0) is above → at least one "Above" chip too.
|
||||||
|
expect(screen.getAllByText(/Below England average/).length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText(/Above England average/).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Getting a place — phase and school-type correctness (expert sign-off
|
||||||
|
* must-fixes M1/M3):
|
||||||
|
* - an all-through school's Year 7 round must never render on the primary
|
||||||
|
* tab as if it were Reception odds;
|
||||||
|
* - selective schools get entrance-test framing, and the secondary tab
|
||||||
|
* never shows the primaries' distance template.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { CompareAdmissions } from '@/components/compare/CompareAdmissions';
|
||||||
|
import type { ComparisonData, School, SchoolAdmissions } from '@/lib/types';
|
||||||
|
|
||||||
|
function school(urn: number, name: string, extra: Partial<School> = {}): School {
|
||||||
|
return { urn, school_name: name, ...extra } as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
|
||||||
|
return {
|
||||||
|
year: 202627,
|
||||||
|
school_phase: 'Secondary',
|
||||||
|
places_offered: 173,
|
||||||
|
total_applications: 433,
|
||||||
|
first_preference_offer_pct: 83,
|
||||||
|
oversubscribed: true,
|
||||||
|
...partial,
|
||||||
|
} as SchoolAdmissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(info: School, a: SchoolAdmissions | null): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: info,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: a,
|
||||||
|
admissions_history: a ? [a] : [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CompareAdmissions', () => {
|
||||||
|
it("does not show an all-through school's Year 7 round on the primary tab", () => {
|
||||||
|
// The real M1 scenario: an all-through school (Year 7 round only) beside
|
||||||
|
// a primary with a Reception round.
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const primary = school(138690, 'Barclay Primary School');
|
||||||
|
const data = {
|
||||||
|
'137306': entry(allThrough, admissions({ school_phase: 'Secondary' })),
|
||||||
|
'138690': entry(
|
||||||
|
primary,
|
||||||
|
admissions({
|
||||||
|
school_phase: 'Primary',
|
||||||
|
total_applications: 300,
|
||||||
|
places_offered: 120,
|
||||||
|
first_preference_offer_pct: 96,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough, primary]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
// Hessle's Year 7 figures must not appear…
|
||||||
|
expect(screen.queryByText('433')).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/We don't hold Reception admissions data for this school/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// …while Barclay's Reception round renders normally.
|
||||||
|
expect(screen.getByText('300')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('phase-labels the section empty state when no matching round exists at all', () => {
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/No Reception admissions data is available for these schools yet/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('433')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Year 7 round on the secondary tab', () => {
|
||||||
|
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||||
|
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('433')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('173')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives selective schools entrance-test framing, never the distance template', () => {
|
||||||
|
const grammar = school(136276, 'Watford Grammar School for Boys', {
|
||||||
|
admissions_policy: 'Selective',
|
||||||
|
religious_denomination: 'Church of England',
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
'136276': entry(grammar, admissions({ first_preference_offer_pct: 43.7 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[grammar]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Entry is by entrance test — the school is selective/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('secondary faith school gets faith-aware copy, not the primaries template', () => {
|
||||||
|
const faithSchool = school(102052, "Bishop Stopford's School", {
|
||||||
|
admissions_policy: 'Non-selective',
|
||||||
|
religious_denomination: 'Church of England',
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
'102052': entry(faithSchool, admissions({ first_preference_offer_pct: 68 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[faithSchool]} data={data} isSecondary={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/faith-based criteria may apply/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the reviewed distance copy for oversubscribed non-faith primaries', () => {
|
||||||
|
const primary = school(100140, 'Plumcroft Primary School');
|
||||||
|
const data = {
|
||||||
|
'100140': entry(
|
||||||
|
primary,
|
||||||
|
admissions({ school_phase: 'Primary', first_preference_offer_pct: 73.4 }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CompareAdmissions schools={[primary]} data={data} isSecondary={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/for most non-faith primaries, distance decides/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,6 +96,70 @@ describe('CompareOfsted', () => {
|
|||||||
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
|
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never renders Ofsted sentinel codes (9 = not applicable) as judgement chips', () => {
|
||||||
|
const sentinelSchool = school(6, 'Sentinel School');
|
||||||
|
const sentinelData: Record<string, ComparisonData> = {
|
||||||
|
'6': {
|
||||||
|
school_info: sentinelSchool,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({
|
||||||
|
overall_effectiveness: 2,
|
||||||
|
grade_source: 'graded',
|
||||||
|
quality_of_education: 1,
|
||||||
|
early_years_provision: 9,
|
||||||
|
sixth_form_provision: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<CompareOfsted schools={[sentinelSchool]} data={sentinelData} />);
|
||||||
|
// Real grades render…
|
||||||
|
expect(screen.getByText('Quality of education')).toBeInTheDocument();
|
||||||
|
// …the applicable sixth-form judgement renders (was previously dropped)…
|
||||||
|
expect(screen.getByText('Sixth form provision')).toBeInTheDocument();
|
||||||
|
// …and the not-applicable sentinel never appears, neither as area nor code.
|
||||||
|
expect(screen.queryByText('Early years provision')).toBeNull();
|
||||||
|
expect(screen.queryByText('9')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
||||||
|
const cardSchool = school(4, 'Dated Card School');
|
||||||
|
const cardData: Record<string, ComparisonData> = {
|
||||||
|
'4': {
|
||||||
|
school_info: cardSchool,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({
|
||||||
|
inspection_date: '2021-10-07',
|
||||||
|
rc_inspection_date: '2026-02-03',
|
||||||
|
rc_safeguarding_met: true,
|
||||||
|
report_card: { rc_achievement: { code: 1, label: 'Exceptional' } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<CompareOfsted schools={[cardSchool]} data={cardData} />);
|
||||||
|
expect(screen.getByText(/3 Feb 2026/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||||
|
expect(screen.queryByText('4+ years ago')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an em dash when a report card has no rc_inspection_date yet', () => {
|
||||||
|
const cardSchool = school(5, 'Undated Card School');
|
||||||
|
const cardData: Record<string, ComparisonData> = {
|
||||||
|
'5': {
|
||||||
|
school_info: cardSchool,
|
||||||
|
yearly_data: [],
|
||||||
|
ofsted: ofsted({
|
||||||
|
inspection_date: '2021-10-07',
|
||||||
|
rc_inspection_date: null,
|
||||||
|
rc_safeguarding_met: true,
|
||||||
|
report_card: { rc_achievement: { code: 1, label: 'Exceptional' } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<CompareOfsted schools={[cardSchool]} data={cardData} />);
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders a per-measure mobile tag with the short school name', () => {
|
it('renders a per-measure mobile tag with the short school name', () => {
|
||||||
render(<CompareOfsted schools={schools} data={data} />);
|
render(<CompareOfsted schools={schools} data={data} />);
|
||||||
// Each measure repeats the schools, so the short name ("Graded" from
|
// Each measure repeats the schools, so the short name ("Graded" from
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Regression: an all-secondary comparison must render the secondary sections.
|
||||||
|
*
|
||||||
|
* The basket hydrates from the URL a beat after mount, so the auto-phase
|
||||||
|
* effect must re-run once selectedSchools arrives — with deps of only
|
||||||
|
* [comparisonData] it fired once against an empty basket, bailed, and the
|
||||||
|
* page stayed on an empty "primary" tab ("No primary schools in your
|
||||||
|
* comparison") even though all schools were secondary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
|
|
||||||
|
const fetchComparison = jest.fn();
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||||
|
|
||||||
|
function secondarySchool(urn: number, name: string): School {
|
||||||
|
return {
|
||||||
|
urn,
|
||||||
|
school_name: name,
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Academy converter',
|
||||||
|
attainment_8_score: 55,
|
||||||
|
phase: 'Secondary',
|
||||||
|
} as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number, name: string): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: secondarySchool(urn, name),
|
||||||
|
yearly_data: [{ year: 202425, attainment_8_score: 55 }] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DATA = {
|
||||||
|
'300': data(300, 'Gamma High'),
|
||||||
|
'400': data(400, 'Delta Academy'),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('an all-secondary comparison renders the sections, not an empty primary tab', async () => {
|
||||||
|
render(
|
||||||
|
<ComparisonProvider>
|
||||||
|
<ComparisonView
|
||||||
|
initialData={INITIAL_DATA}
|
||||||
|
initialNationalAverages={{
|
||||||
|
year: 202425,
|
||||||
|
primary: {},
|
||||||
|
secondary: { attainment_8_score: 46 },
|
||||||
|
by_year: [],
|
||||||
|
}}
|
||||||
|
initialBenchmarks={undefined}
|
||||||
|
initialUrns={[300, 400]}
|
||||||
|
metrics={[]}
|
||||||
|
selectedMetric="attainment_8_score"
|
||||||
|
/>
|
||||||
|
</ComparisonProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
|
||||||
|
// The sticky bar's rail caption reflects the active phase and count.
|
||||||
|
expect(screen.getByText('2 secondary schools')).toBeInTheDocument();
|
||||||
|
expect(fetchComparison).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Regression: opening a compare link while a DIFFERENT basket is stored must
|
||||||
|
* not blank the page.
|
||||||
|
*
|
||||||
|
* The basket hydrates from localStorage first, which can fire a fetch for the
|
||||||
|
* OLD school set; the URL-seed effect then replaces the basket with the URL's
|
||||||
|
* schools (already covered by SSR data, so no new fetch). When the stale
|
||||||
|
* response for the old set finally lands, it must not clobber the fresh SSR
|
||||||
|
* data — that left every section (including the trends chart) empty until a
|
||||||
|
* hard refresh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
|
|
||||||
|
const fetchComparison = jest.fn();
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||||
|
}));
|
||||||
|
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||||
|
|
||||||
|
function school(urn: number, name: string): School {
|
||||||
|
return {
|
||||||
|
urn,
|
||||||
|
school_name: name,
|
||||||
|
local_authority: 'Testshire',
|
||||||
|
school_type: 'Community school',
|
||||||
|
rwm_expected_pct: 80,
|
||||||
|
phase: 'Primary',
|
||||||
|
} as School;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(urn: number, name: string): ComparisonData {
|
||||||
|
return {
|
||||||
|
school_info: school(urn, name),
|
||||||
|
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
|
||||||
|
ofsted: null,
|
||||||
|
census: null,
|
||||||
|
admissions: null,
|
||||||
|
admissions_history: [],
|
||||||
|
deprivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The visitor's previously stored basket (a different school entirely).
|
||||||
|
const STORED_SCHOOL = school(900, 'Old Stored School');
|
||||||
|
|
||||||
|
// The comparison the URL (and SSR) actually asked for.
|
||||||
|
const URL_DATA = {
|
||||||
|
'100': data(100, 'Alpha Primary'),
|
||||||
|
'200': data(200, 'Beta Primary'),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchComparison.mockReset();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale fetch for the previously stored basket does not clobber the URL comparison', async () => {
|
||||||
|
localStorage.setItem('selectedSchools', JSON.stringify([STORED_SCHOOL]));
|
||||||
|
|
||||||
|
const pending: Array<(v: unknown) => void> = [];
|
||||||
|
fetchComparison.mockImplementation(() => new Promise((resolve) => pending.push(resolve)));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ComparisonProvider>
|
||||||
|
<ComparisonView
|
||||||
|
initialData={URL_DATA}
|
||||||
|
initialNationalAverages={{
|
||||||
|
year: 202425,
|
||||||
|
primary: { rwm_expected_pct: 62 },
|
||||||
|
secondary: {},
|
||||||
|
by_year: [],
|
||||||
|
}}
|
||||||
|
initialBenchmarks={undefined}
|
||||||
|
initialUrns={[100, 200]}
|
||||||
|
metrics={[]}
|
||||||
|
selectedMetric="rwm_expected_pct"
|
||||||
|
/>
|
||||||
|
</ComparisonProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The URL's schools render from SSR data once the basket is reseeded.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The transient stored-basket fetch (for school 900) resolves LATE, after
|
||||||
|
// the basket has moved on to the URL's schools.
|
||||||
|
await act(async () => {
|
||||||
|
for (const resolve of pending) {
|
||||||
|
resolve({
|
||||||
|
comparison: { '900': data(900, 'Old Stored School') },
|
||||||
|
national_averages: { year: 202425, primary: {}, secondary: {}, by_year: [] },
|
||||||
|
benchmarks: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page must still show the URL comparison — not go blank.
|
||||||
|
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* SpecialSchoolNote Component Tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { SpecialSchoolNote } from '@/components/SpecialSchoolNote';
|
||||||
|
|
||||||
|
describe('SpecialSchoolNote', () => {
|
||||||
|
it('renders nothing for a mainstream school', () => {
|
||||||
|
const { container } = render(<SpecialSchoolNote school={{ school_type: 'Academy converter' }} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a special school as teaching pupils with SEND', () => {
|
||||||
|
render(<SpecialSchoolNote school={{ school_type: 'Community special school' }} />);
|
||||||
|
expect(screen.getByText(/This is a special school/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/special educational needs/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call PRU pupils SEND — they are educated outside a mainstream school', () => {
|
||||||
|
render(<SpecialSchoolNote school={{ school_type: 'Pupil referral unit' }} />);
|
||||||
|
expect(screen.getByText(/This is a pupil referral unit/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/educated outside a mainstream school/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call alternative-provision pupils SEND', () => {
|
||||||
|
render(<SpecialSchoolNote school={{ school_type: 'Academy alternative provision converter' }} />);
|
||||||
|
expect(screen.getByText(/This is an alternative provision setting/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,6 +73,15 @@ describe('buildCompareChart', () => {
|
|||||||
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
|
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists England-only years so the component can caption dashed-only stretches', () => {
|
||||||
|
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||||
|
202122: 58.7,
|
||||||
|
});
|
||||||
|
expect(chart.englandOnlyYears).toEqual([202122]);
|
||||||
|
const none = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||||
|
expect(none.englandOnlyYears).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
|
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
|
||||||
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||||
202122: 58.7,
|
202122: 58.7,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
OFSTED_LEGACY_GRADES,
|
OFSTED_LEGACY_GRADES,
|
||||||
|
admissionsForPhase,
|
||||||
ofstedDisplay,
|
ofstedDisplay,
|
||||||
progressBand,
|
progressBand,
|
||||||
rcAreaLabel,
|
rcAreaLabel,
|
||||||
@@ -176,6 +177,16 @@ describe('summariseAdmissions', () => {
|
|||||||
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
|
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('60% → "About 1 in 3 first choices missed out"', () => {
|
||||||
|
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 60 }));
|
||||||
|
expect(s.chip).toEqual({ tone: 'warn', text: 'About 1 in 3 first choices missed out' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('44% (selective-scale demand) → "More than half of first choices missed out"', () => {
|
||||||
|
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 43.69 }));
|
||||||
|
expect(s.chip).toEqual({ tone: 'warn', text: 'More than half of first choices missed out' });
|
||||||
|
});
|
||||||
|
|
||||||
it('100% → "All first choices offered"', () => {
|
it('100% → "All first choices offered"', () => {
|
||||||
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
|
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
|
||||||
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
|
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
|
||||||
@@ -188,6 +199,44 @@ describe('summariseAdmissions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('admissionsForPhase', () => {
|
||||||
|
const row = (year: number, school_phase: string | null): SchoolAdmissions =>
|
||||||
|
({ year, school_phase, places_offered: 100, total_applications: 200, first_preference_offer_pct: 80 }) as SchoolAdmissions;
|
||||||
|
|
||||||
|
it('returns the latest round matching the active phase', () => {
|
||||||
|
const data = {
|
||||||
|
admissions: row(202627, 'Secondary'),
|
||||||
|
admissions_history: [row(202526, 'Secondary'), row(202526, 'Primary'), row(202425, 'Primary')],
|
||||||
|
};
|
||||||
|
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||||
|
expect(admissionsForPhase(data, false)?.year).toBe(202526);
|
||||||
|
expect(admissionsForPhase(data, false)?.school_phase).toBe('Primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never substitutes the other phase's round (all-through with Year 7 data only)", () => {
|
||||||
|
const data = {
|
||||||
|
admissions: row(202627, 'Secondary'),
|
||||||
|
admissions_history: [row(202526, 'Secondary')],
|
||||||
|
};
|
||||||
|
expect(admissionsForPhase(data, false)).toBeNull();
|
||||||
|
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses untagged legacy rows only when no row carries a phase', () => {
|
||||||
|
const untagged = { admissions: row(202627, null), admissions_history: [row(202526, null)] };
|
||||||
|
expect(admissionsForPhase(untagged, false)?.year).toBe(202627);
|
||||||
|
expect(admissionsForPhase(untagged, true)?.year).toBe(202627);
|
||||||
|
|
||||||
|
const mixed = { admissions: row(202627, 'Secondary'), admissions_history: [row(202526, null)] };
|
||||||
|
expect(admissionsForPhase(mixed, false)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing data', () => {
|
||||||
|
expect(admissionsForPhase(null, false)).toBeNull();
|
||||||
|
expect(admissionsForPhase({ admissions: null, admissions_history: [] }, true)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('progressBand', () => {
|
describe('progressBand', () => {
|
||||||
it('CI entirely above zero → above', () => {
|
it('CI entirely above zero → above', () => {
|
||||||
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
|
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isValidPostcode,
|
isValidPostcode,
|
||||||
debounce,
|
debounce,
|
||||||
buildOfstedListBadge,
|
buildOfstedListBadge,
|
||||||
|
ofstedLegacyAreas,
|
||||||
metricKind,
|
metricKind,
|
||||||
shortName,
|
shortName,
|
||||||
computeYBounds,
|
computeYBounds,
|
||||||
@@ -111,6 +112,35 @@ describe('debounce', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ofstedLegacyAreas', () => {
|
||||||
|
it('returns only real grades (1-4) and drops sentinel/null values', () => {
|
||||||
|
const areas = ofstedLegacyAreas({
|
||||||
|
quality_of_education: 1,
|
||||||
|
behaviour_attitudes: 2,
|
||||||
|
personal_development: null,
|
||||||
|
leadership_management: 9, // sentinel "not applicable" — must be dropped
|
||||||
|
early_years_provision: 9, // secondary school — not applicable
|
||||||
|
sixth_form_provision: 2,
|
||||||
|
});
|
||||||
|
expect(areas.map((a) => a.label)).toEqual([
|
||||||
|
'Quality of Teaching',
|
||||||
|
'Behaviour in School',
|
||||||
|
'Sixth Form',
|
||||||
|
]);
|
||||||
|
expect(areas.every((a) => a.value >= 1 && a.value <= 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the sixth-form judgement where present (was previously dropped)', () => {
|
||||||
|
const areas = ofstedLegacyAreas({ sixth_form_provision: 1 });
|
||||||
|
expect(areas).toEqual([{ label: 'Sixth Form', value: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes Early Years for a primary school', () => {
|
||||||
|
const areas = ofstedLegacyAreas({ quality_of_education: 2, early_years_provision: 1 });
|
||||||
|
expect(areas.map((a) => a.label)).toContain('Early Years (Reception)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildOfstedListBadge', () => {
|
describe('buildOfstedListBadge', () => {
|
||||||
it('returns grade word + year for OEIF Outstanding', () => {
|
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_date: '2023-11-15', ofsted_framework: 'OEIF' });
|
||||||
@@ -130,9 +160,23 @@ describe('buildOfstedListBadge', () => {
|
|||||||
expect(badge.cssClass).toBe('ofsted2');
|
expect(badge.cssClass).toBe('ofsted2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns Report Card badge when framework is ReportCard', () => {
|
it('returns a Report Card badge when ofsted_rc_date is present', () => {
|
||||||
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2025-11-01', ofsted_framework: 'ReportCard' });
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' });
|
||||||
expect(badge.label).toBe('Report Card · 2025');
|
expect(badge.label).toBe('Report Card · 2026');
|
||||||
|
expect(badge.cssClass).toBe('ofstedRc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a report card wins over a carried-forward legacy grade', () => {
|
||||||
|
// The production bug: a report-card school (e.g. Barclay) also carries a
|
||||||
|
// carried-forward legacy grade (ofsted_grade), which used to win and label
|
||||||
|
// the pin "Outstanding · 2021" instead of "Report Card · 2026".
|
||||||
|
const badge = buildOfstedListBadge({
|
||||||
|
ofsted_grade: 1,
|
||||||
|
ofsted_date: '2021-10-07',
|
||||||
|
ofsted_framework: 'Schools - S5',
|
||||||
|
ofsted_rc_date: '2026-02-03',
|
||||||
|
});
|
||||||
|
expect(badge.label).toBe('Report Card · 2026');
|
||||||
expect(badge.cssClass).toBe('ofstedRc');
|
expect(badge.cssClass).toBe('ofstedRc');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,6 +269,44 @@ describe('isProposedToClose', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isSpecialSchool', () => {
|
||||||
|
const { isSpecialSchool } = require('@/lib/utils');
|
||||||
|
|
||||||
|
it('matches every DfE special-school establishment type', () => {
|
||||||
|
for (const t of [
|
||||||
|
'Community special school',
|
||||||
|
'Foundation special school',
|
||||||
|
'Academy special converter',
|
||||||
|
'Academy special sponsor led',
|
||||||
|
'Non-maintained special school',
|
||||||
|
'Free schools special',
|
||||||
|
'Other independent special school',
|
||||||
|
]) {
|
||||||
|
expect(isSpecialSchool({ school_type: t })).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches pupil referral units and alternative provision', () => {
|
||||||
|
expect(isSpecialSchool({ school_type: 'Pupil referral unit' })).toBe(true);
|
||||||
|
expect(isSpecialSchool({ school_type: 'Academy alternative provision converter' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match mainstream types (no false positives)', () => {
|
||||||
|
for (const t of [
|
||||||
|
'Academy converter',
|
||||||
|
'Community school',
|
||||||
|
'Voluntary aided school',
|
||||||
|
'Free schools',
|
||||||
|
'University technical college',
|
||||||
|
'Other independent school',
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
]) {
|
||||||
|
expect(isSpecialSchool({ school_type: t })).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('shortName', () => {
|
describe('shortName', () => {
|
||||||
it('drops the trailing establishment-type words', () => {
|
it('drops the trailing establishment-type words', () => {
|
||||||
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
||||||
|
|||||||
@@ -183,10 +183,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary: teal outline — supporting actions (+ Compare) */
|
/* Secondary: teal outline — supporting actions (+ Compare) */
|
||||||
|
/* NOTE: a duplicate `.btn` block further down this file sets `border: none`,
|
||||||
|
which wins over the base `.btn`'s `1px solid transparent`. The outline
|
||||||
|
variants below therefore declare the full `border` shorthand explicitly so
|
||||||
|
they don't depend on the base border-width — otherwise `border-color` alone
|
||||||
|
has no width and the outline never renders (buttons read as plain text). */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--accent-teal);
|
color: var(--accent-teal);
|
||||||
border-color: var(--accent-teal);
|
border: 1px solid var(--accent-teal);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--accent-teal-bg);
|
background: var(--accent-teal-bg);
|
||||||
@@ -196,7 +201,7 @@ body {
|
|||||||
.btn-tertiary {
|
.btn-tertiary {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-color: var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
.btn-tertiary:hover:not(:disabled) {
|
.btn-tertiary:hover:not(:disabled) {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
@@ -207,7 +212,7 @@ body {
|
|||||||
.btn-active {
|
.btn-active {
|
||||||
background: var(--accent-teal-bg);
|
background: var(--accent-teal-bg);
|
||||||
color: var(--accent-teal);
|
color: var(--accent-teal);
|
||||||
border-color: var(--accent-teal);
|
border: 1px solid var(--accent-teal);
|
||||||
}
|
}
|
||||||
.btn-active:hover:not(:disabled) {
|
.btn-active:hover:not(:disabled) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ export default function RootLayout({
|
|||||||
data-performance="true"
|
data-performance="true"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
|
{/* Heatmap / session recorder — same data-domains guard so staging
|
||||||
|
(same image, different host) never feeds the heatmap. */}
|
||||||
|
<Script
|
||||||
|
src="https://analytics.schoolcompare.co.uk/recorder.js"
|
||||||
|
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||||
|
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
||||||
<div className="noise-overlay" />
|
<div className="noise-overlay" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Line } from 'react-chartjs-2';
|
|||||||
import { ChartOptions } from 'chart.js';
|
import { ChartOptions } from 'chart.js';
|
||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
|
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||||
import type { SchoolAdmissions } from '@/lib/types';
|
import type { SchoolAdmissions } from '@/lib/types';
|
||||||
import styles from './AdmissionsTrendChart.module.css';
|
import styles from './AdmissionsTrendChart.module.css';
|
||||||
|
|
||||||
@@ -17,13 +18,22 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
|||||||
const pts = history.filter((h) => h.first_preference_offer_pct != null);
|
const pts = history.filter((h) => h.first_preference_offer_pct != null);
|
||||||
if (pts.length < 2) return null;
|
if (pts.length < 2) return null;
|
||||||
|
|
||||||
const labels = pts.map((p) => formatAcademicYear(p.year));
|
// Gap-honest axis: every academic year between the first and last data point
|
||||||
const values = pts.map((p) => p.first_preference_offer_pct as number);
|
// appears, so a missing admissions year renders as a real gap (spanGaps:false)
|
||||||
const lastIdx = pts.length - 1;
|
// rather than compressing time between distant years.
|
||||||
|
const axisYears = fillAcademicYears(pts.map((p) => p.year));
|
||||||
|
const byYear = new Map(pts.map((p) => [p.year, p.first_preference_offer_pct as number]));
|
||||||
|
const labels = axisYears.map(formatAcademicYear);
|
||||||
|
const values: (number | null)[] = axisYears.map((y) => byYear.get(y) ?? null);
|
||||||
|
const present = values
|
||||||
|
.map((v, i) => (v != null ? i : -1))
|
||||||
|
.filter((i) => i >= 0);
|
||||||
|
const lastIdx = present[present.length - 1];
|
||||||
|
|
||||||
// Auto-scale with headroom so variation is visible, clamped to 0–100.
|
// Auto-scale with headroom so variation is visible, clamped to 0–100.
|
||||||
const lo = Math.min(...values);
|
const numeric = values.filter((v): v is number => v != null);
|
||||||
const hi = Math.max(...values);
|
const lo = Math.min(...numeric);
|
||||||
|
const hi = Math.max(...numeric);
|
||||||
const padded = Math.max(5, Math.round((hi - lo) * 0.25));
|
const padded = Math.max(5, Math.round((hi - lo) * 0.25));
|
||||||
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
||||||
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
||||||
@@ -69,15 +79,16 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
|||||||
label: 'First-choice offer rate',
|
label: 'First-choice offer rate',
|
||||||
data: values,
|
data: values,
|
||||||
clip: false as const,
|
clip: false as const,
|
||||||
|
spanGaps: false,
|
||||||
borderColor: '#e07256',
|
borderColor: '#e07256',
|
||||||
backgroundColor: 'rgba(224,114,86,0.10)',
|
backgroundColor: 'rgba(224,114,86,0.10)',
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: true,
|
fill: true,
|
||||||
pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)),
|
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||||
pointBackgroundColor: '#e07256',
|
pointBackgroundColor: '#e07256',
|
||||||
pointBorderColor: '#fff',
|
pointBorderColor: '#fff',
|
||||||
pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)),
|
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||||
pointHoverRadius: 6,
|
pointHoverRadius: 6,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -442,6 +442,58 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Easy-to-miss callouts ──────────────────────────── */
|
||||||
|
|
||||||
|
.calloutGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
|
||||||
|
border: 1px solid rgba(201, 162, 39, 0.28);
|
||||||
|
border-left: 3px solid var(--accent-gold, #c9a227);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calloutIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-gold, #c9a227);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calloutText {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calloutTitle {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-gold-text, #7a6800);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calloutBody {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Tips ───────────────────────────────────────────── */
|
/* ─── Tips ───────────────────────────────────────────── */
|
||||||
|
|
||||||
.tips {
|
.tips {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ interface Step {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
highlight?: 'deadline' | 'offer';
|
highlight?: 'deadline' | 'offer';
|
||||||
|
/* Easy-to-miss, deadline-sensitive warnings surfaced as callout boxes
|
||||||
|
rather than buried in the body prose. */
|
||||||
|
callouts?: { title: string; body: string }[];
|
||||||
/* How SchoolCompare helps at this stage of the journey */
|
/* How SchoolCompare helps at this stage of the journey */
|
||||||
tool?: { label: string; href: string };
|
tool?: { label: string; href: string };
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,16 @@ const SECONDARY_STEPS: Step[] = [
|
|||||||
{
|
{
|
||||||
title: 'Check entry criteria',
|
title: 'Check entry criteria',
|
||||||
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
|
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
|
||||||
|
callouts: [
|
||||||
|
{
|
||||||
|
title: 'Selective schools register earlier',
|
||||||
|
body: 'Grammar and other selective schools need separate entrance-test registration months earlier — often by September, well before the 31 October deadline.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Faith schools may need an extra form',
|
||||||
|
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, on top of the council application.',
|
||||||
|
},
|
||||||
|
],
|
||||||
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,14 +86,14 @@ const SECONDARY_STEPS: Step[] = [
|
|||||||
{
|
{
|
||||||
date: '31 October',
|
date: '31 October',
|
||||||
title: 'Application deadline',
|
title: 'Application deadline',
|
||||||
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally — list schools in the genuine order you want them, not strategically.',
|
body: 'Submit your ranked list of schools — most councils allow three to six preferences (London boroughs typically six). Councils treat all preferences equally, so list schools in the genuine order you want them, not strategically.',
|
||||||
highlight: 'deadline',
|
highlight: 'deadline',
|
||||||
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
|
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '1 March',
|
date: '1 March',
|
||||||
title: 'National Offer Day',
|
title: 'National Offer Day',
|
||||||
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
|
body: 'Your allocated school appears on the council\'s online admissions portal and is sent by email. Release times are set by each council — often from late afternoon, though some publish overnight — so check your council\'s page rather than refreshing at midnight.',
|
||||||
highlight: 'offer',
|
highlight: 'offer',
|
||||||
tool: { label: 'Look up your allocated school', href: '/' },
|
tool: { label: 'Look up your allocated school', href: '/' },
|
||||||
},
|
},
|
||||||
@@ -101,6 +114,12 @@ const PRIMARY_STEPS: Step[] = [
|
|||||||
{
|
{
|
||||||
title: 'Research entry criteria',
|
title: 'Research entry criteria',
|
||||||
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
|
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
|
||||||
|
callouts: [
|
||||||
|
{
|
||||||
|
title: 'Faith schools may need an extra form',
|
||||||
|
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, in addition to the council application.',
|
||||||
|
},
|
||||||
|
],
|
||||||
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
tool: { label: 'Find schools & view their admissions history', href: '/' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -144,7 +163,7 @@ interface Tip {
|
|||||||
const TIPS: Tip[] = [
|
const TIPS: Tip[] = [
|
||||||
{
|
{
|
||||||
heading: 'Equal preference rule',
|
heading: 'Equal preference rule',
|
||||||
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually want them.',
|
body: 'Councils consider every school you list before making any offer, then give you the highest-ranked one you qualify for. Your order only decides which qualifying school you get — you can\'t game it, so list schools in the order you genuinely want them.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'Late applications go to the back',
|
heading: 'Late applications go to the back',
|
||||||
@@ -307,6 +326,19 @@ export function AdmissionsView() {
|
|||||||
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
<div className={styles.stepTitle}>{step.title}</div>
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
<p className={styles.stepBody}>{step.body}</p>
|
<p className={styles.stepBody}>{step.body}</p>
|
||||||
|
{step.callouts && step.callouts.length > 0 && (
|
||||||
|
<div className={styles.calloutGroup}>
|
||||||
|
{step.callouts.map((c) => (
|
||||||
|
<div key={c.title} className={styles.callout}>
|
||||||
|
<span className={styles.calloutIcon} aria-hidden="true">!</span>
|
||||||
|
<div className={styles.calloutText}>
|
||||||
|
<div className={styles.calloutTitle}>{c.title}</div>
|
||||||
|
<p className={styles.calloutBody}>{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{step.tool && (
|
{step.tool && (
|
||||||
<a
|
<a
|
||||||
href={step.tool.href}
|
href={step.tool.href}
|
||||||
@@ -355,6 +387,19 @@ export function AdmissionsView() {
|
|||||||
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
<div className={styles.stepTitle}>{step.title}</div>
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
<p className={styles.stepBody}>{step.body}</p>
|
<p className={styles.stepBody}>{step.body}</p>
|
||||||
|
{step.callouts && step.callouts.length > 0 && (
|
||||||
|
<div className={styles.calloutGroup}>
|
||||||
|
{step.callouts.map((c) => (
|
||||||
|
<div key={c.title} className={styles.callout}>
|
||||||
|
<span className={styles.calloutIcon} aria-hidden="true">!</span>
|
||||||
|
<div className={styles.calloutText}>
|
||||||
|
<div className={styles.calloutTitle}>{c.title}</div>
|
||||||
|
<p className={styles.calloutBody}>{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{step.tool && (
|
{step.tool && (
|
||||||
<a
|
<a
|
||||||
href={step.tool.href}
|
href={step.tool.href}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
|
/* Chart wrapper: chips (mobile) above, then the canvas, then the gap note.
|
||||||
parent .chartContainer, whose fixed height drives Chart.js sizing via
|
The canvas has its OWN definite height (Chart.js needs one for
|
||||||
maintainAspectRatio: false. */
|
maintainAspectRatio: false); the chips and the note flow at their natural
|
||||||
|
size around it rather than competing with it for a fixed outer height —
|
||||||
|
so a longer note (e.g. the KS4 gap caption) or a two-row chip legend can
|
||||||
|
never squash the chart. */
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasBox {
|
.canvasBox {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
height: 380px;
|
||||||
min-height: 0;
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.canvasBox {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
|
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
|
||||||
|
|||||||
@@ -38,13 +38,15 @@ interface ComparisonChartProps {
|
|||||||
/** Official England figure per academic year for this metric — renders a
|
/** Official England figure per academic year for this metric — renders a
|
||||||
* dashed grey reference line when provided. */
|
* dashed grey reference line when provided. */
|
||||||
nationalByYear?: Record<number, number | null | undefined>;
|
nationalByYear?: Record<number, number | null | undefined>;
|
||||||
|
/** KS4 metrics get a different (honest) gap caption than KS2. */
|
||||||
|
isSecondary?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
||||||
// converging lines stay tellable apart without relying on hue alone.
|
// converging lines stay tellable apart without relying on hue alone.
|
||||||
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
||||||
|
|
||||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
|
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -168,7 +170,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
|||||||
display: true,
|
display: true,
|
||||||
title: {
|
title: {
|
||||||
display: !isMobile,
|
display: !isMobile,
|
||||||
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
|
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
|
||||||
font: {
|
font: {
|
||||||
size: 12,
|
size: 12,
|
||||||
weight: 'bold',
|
weight: 'bold',
|
||||||
@@ -240,11 +242,23 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
|||||||
<div className={styles.canvasBox}>
|
<div className={styles.canvasBox}>
|
||||||
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
||||||
</div>
|
</div>
|
||||||
{built.showUnpublished202122Note && (
|
{isSecondary && built.englandOnlyYears.length > 0 ? (
|
||||||
|
// KS4's honest story differs from KS2's: 2019/20–2020/21 school-level
|
||||||
|
// GCSE results weren't published (COVID grading); later years WERE
|
||||||
|
// published by DfE but aren't in our dataset yet.
|
||||||
|
<p className={styles.chartNote}>
|
||||||
|
School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID
|
||||||
|
grading), and more recent years aren't in our dataset yet where lines break — the
|
||||||
|
England average is shown where available.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
!isSecondary &&
|
||||||
|
built.showUnpublished202122Note && (
|
||||||
<p className={styles.chartNote}>
|
<p className={styles.chartNote}>
|
||||||
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish
|
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish
|
||||||
school-level figures for 2021/22 — the England average is shown for that year.
|
school-level figures for 2021/22 — the England average is shown for that year.
|
||||||
</p>
|
</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,6 +148,54 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Caption filling the label rail on desktop ("Comparing / 3 primary
|
||||||
|
schools"). Hidden on mobile, where the bar is a row of compact pills. */
|
||||||
|
.barCaption {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop (matches the sections' 761px breakpoint): the bar adopts the same
|
||||||
|
grid template as compareSections' .grid — a 200px row-label rail plus one
|
||||||
|
column per school — so each chip sits exactly over the column it labels.
|
||||||
|
The caption occupies the rail; chips flow into the school columns. */
|
||||||
|
@media (min-width: 761px) {
|
||||||
|
.schoolBar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px repeat(var(--school-count, 3), 1fr);
|
||||||
|
gap: 0 0.75rem;
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schoolChip {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barCaption {
|
||||||
|
grid-column: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barCaptionEyebrow {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barCaptionCount {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chipRemove {
|
.chipRemove {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
|
|
||||||
@@ -85,7 +85,9 @@ export function ComparisonView({
|
|||||||
replaceSchools(urlSchools);
|
replaceSchools(urlSchools);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
// Re-seed when a client-side navigation lands on a different ?urns= set
|
||||||
|
// (initialUrns/initialData are new props on the same component instance).
|
||||||
|
}, [isInitialized, initialUrns.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const urnKey = selectedSchools.map((s) => s.urn).join(',');
|
const urnKey = selectedSchools.map((s) => s.urn).join(',');
|
||||||
|
|
||||||
@@ -128,8 +130,18 @@ export function ComparisonView({
|
|||||||
const covered = urnKey.split(',').every((urn) => have[urn] != null);
|
const covered = urnKey.split(',').every((urn) => have[urn] != null);
|
||||||
if (covered) return;
|
if (covered) return;
|
||||||
|
|
||||||
|
// Guard against out-of-order responses: while the basket hydrates from
|
||||||
|
// localStorage it can transiently hold a DIFFERENT school set than the
|
||||||
|
// URL, firing a fetch for schools the user is no longer comparing. That
|
||||||
|
// stale response must not replace data for the current set — it blanked
|
||||||
|
// every section until a hard refresh. Cleanup marks the run cancelled
|
||||||
|
// when urnKey moves on, so only the current selection's response is
|
||||||
|
// applied (replacing the map keeps it bounded and guarantees a re-added
|
||||||
|
// school is refetched fresh rather than served a lingering old entry).
|
||||||
|
let cancelled = false;
|
||||||
fetchComparison(urnKey, { cache: 'no-store' })
|
fetchComparison(urnKey, { cache: 'no-store' })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
setComparisonData(data.comparison);
|
setComparisonData(data.comparison);
|
||||||
setNationalAverages(data.national_averages);
|
setNationalAverages(data.national_averages);
|
||||||
setBenchmarks(data.benchmarks);
|
setBenchmarks(data.benchmarks);
|
||||||
@@ -140,6 +152,9 @@ export function ComparisonView({
|
|||||||
// destroy a working comparison the user is looking at.
|
// destroy a working comparison the user is looking at.
|
||||||
console.error('Failed to fetch comparison:', err);
|
console.error('Failed to fetch comparison:', err);
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [urnKey, isInitialized]);
|
}, [urnKey, isInitialized]);
|
||||||
|
|
||||||
const primarySchools = selectedSchools.filter((school) => {
|
const primarySchools = selectedSchools.filter((school) => {
|
||||||
@@ -173,7 +188,11 @@ export function ComparisonView({
|
|||||||
if (!metricFitsPhase) {
|
if (!metricFitsPhase) {
|
||||||
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||||
}
|
}
|
||||||
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
// selectedSchools is a dep because the basket hydrates after mount: the
|
||||||
|
// first run sees an empty basket and bails, so it must re-fire when the
|
||||||
|
// schools arrive. primarySchools/secondarySchools/metrics/selectedMetric
|
||||||
|
// are intentionally omitted (derived or would cause loops).
|
||||||
|
}, [comparisonData, selectedSchools]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||||
phaseLockedByUser.current = true;
|
phaseLockedByUser.current = true;
|
||||||
@@ -338,8 +357,28 @@ export function ComparisonView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Sticky school bar — column identity while scrolling */}
|
{/* Sticky school bar — column identity while scrolling. On desktop
|
||||||
<div className={styles.schoolBar} aria-label="Schools in this comparison">
|
it shares the sections' grid template (via --school-count) so
|
||||||
|
each chip sits exactly over the column it labels. */}
|
||||||
|
<div
|
||||||
|
className={styles.schoolBar}
|
||||||
|
style={{ '--school-count': activeSchools.length } as CSSProperties}
|
||||||
|
aria-label="Schools in this comparison"
|
||||||
|
>
|
||||||
|
{/* Fills the 200px label rail on desktop (hidden on mobile).
|
||||||
|
All-through schools must not be miscounted as "primary
|
||||||
|
schools"/"secondary schools" — mixed baskets get "· primary
|
||||||
|
view" phrasing instead. */}
|
||||||
|
<div className={styles.barCaption}>
|
||||||
|
<span className={styles.barCaptionEyebrow}>Comparing</span>
|
||||||
|
<span className={styles.barCaptionCount}>
|
||||||
|
{activeSchools.every((sch) =>
|
||||||
|
sch.phase?.toLowerCase().includes(comparePhase),
|
||||||
|
)
|
||||||
|
? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
|
||||||
|
: `${activeSchools.length} schools · ${comparePhase} view`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{activeSchools.map((school, index) => (
|
{activeSchools.map((school, index) => (
|
||||||
<div
|
<div
|
||||||
key={school.urn}
|
key={school.urn}
|
||||||
@@ -357,7 +396,13 @@ export function ComparisonView({
|
|||||||
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
|
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
|
||||||
</a>
|
</a>
|
||||||
<span className={styles.chipMeta}>
|
<span className={styles.chipMeta}>
|
||||||
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
|
{[
|
||||||
|
/all.?through/i.test(school.phase ?? '') ? 'All-through' : null,
|
||||||
|
school.local_authority,
|
||||||
|
school.school_type,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -389,7 +434,11 @@ export function ComparisonView({
|
|||||||
benchmarks={benchmarks}
|
benchmarks={benchmarks}
|
||||||
isSecondary={!isPrimary}
|
isSecondary={!isPrimary}
|
||||||
/>
|
/>
|
||||||
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
|
<CompareAdmissions
|
||||||
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
isSecondary={!isPrimary}
|
||||||
|
/>
|
||||||
<CompareCommunity
|
<CompareCommunity
|
||||||
schools={activeSchools}
|
schools={activeSchools}
|
||||||
data={activeComparisonData}
|
data={activeComparisonData}
|
||||||
|
|||||||
@@ -13,17 +13,17 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
|
|||||||
<div className={styles.editorialGrid}>
|
<div className={styles.editorialGrid}>
|
||||||
<div className={styles.editorialText}>
|
<div className={styles.editorialText}>
|
||||||
<div className={styles.editorialKicker}>About school data</div>
|
<div className={styles.editorialKicker}>About school data</div>
|
||||||
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
|
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
||||||
<p>
|
<p>
|
||||||
School performance data in England is rich but fragmented. The Department for Education publishes
|
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||||
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
|
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
||||||
demographics — each in its own table, each with its own jargon.
|
and demographics — each in its own table, each with its own jargon.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||||||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||||
40-page inspection report.
|
full inspection report.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.factbox}>
|
<div className={styles.factbox}>
|
||||||
|
|||||||
@@ -720,10 +720,13 @@
|
|||||||
.miniRowHead {
|
.miniRowHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 0.48rem;
|
align-items: baseline;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.44rem;
|
||||||
color: var(--text-muted, #6d685f);
|
color: var(--text-muted, #6d685f);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miniRowHead strong {
|
.miniRowHead strong {
|
||||||
@@ -869,28 +872,77 @@
|
|||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedBadge {
|
.ofstedFramework {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(45, 125, 125, 0.12);
|
background: rgba(90, 58, 110, 0.12);
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: #5a3a6e;
|
||||||
font-size: 0.55rem;
|
font-size: 0.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedVerdict {
|
.rcList {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
display: flex;
|
||||||
font-size: 1.05rem;
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcArea {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text-secondary, #4a453d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcChip {
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.52rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
letter-spacing: 0.03em;
|
||||||
color: var(--text-primary, #1a1612);
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedVerdict em {
|
.rcChipGood {
|
||||||
|
background: rgba(45, 125, 125, 0.12);
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--accent-teal, #2d7d7d);
|
||||||
font-style: normal;
|
}
|
||||||
|
|
||||||
|
.rcChipNeutral {
|
||||||
|
background: var(--accent-gold-bg, rgba(180, 150, 40, 0.12));
|
||||||
|
color: var(--accent-gold-text, #7a6800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safeguarding is a separate binary met/not-met judgement, not a graded area,
|
||||||
|
so it sits below the five-point list behind a divider with its own chip. */
|
||||||
|
.rcSafeguarding {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
border-top: 1px dashed var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcSep {
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcChipMet {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
border: 1px solid rgba(45, 125, 125, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedMeta {
|
.ofstedMeta {
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
<div className={styles.heroSection}>
|
<div className={styles.heroSection}>
|
||||||
<span className={styles.heroEyebrow}>
|
<span className={styles.heroEyebrow}>
|
||||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||||
Updated with 2026/2027 admissions results
|
Updated for the 2026/27 admissions round
|
||||||
</span>
|
</span>
|
||||||
<h1 className={styles.heroTitle}>
|
<h1 className={styles.heroTitle}>
|
||||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||||
|
|||||||
@@ -5,15 +5,26 @@
|
|||||||
import styles from './HomeView.module.css';
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
export function HowItWorksSection() {
|
export function HowItWorksSection() {
|
||||||
|
// "exc" is the school's above-expected result. DfE names this differently
|
||||||
|
// by subject: "higher standard" for the reading and maths tests, "greater
|
||||||
|
// depth" for teacher-assessed writing. "nat" is the national expected %.
|
||||||
const miniCascade = [
|
const miniCascade = [
|
||||||
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
|
{ subj: 'Reading', exp: 96, exc: 44, nat: 75, excLabel: 'Higher std' },
|
||||||
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
|
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||||
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
|
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||||
];
|
];
|
||||||
const compareRows = [
|
const compareRows = [
|
||||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||||
|
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||||
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
||||||
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
|
];
|
||||||
|
// Report card (Ofsted's framework from Nov 2025): one rating per area of
|
||||||
|
// school life on a five-point scale. Safeguarding is judged separately, as a
|
||||||
|
// binary met/not-met outcome — kept out of this list and shown on its own.
|
||||||
|
const reportCard: Array<{ area: string; grade: string; good: boolean }> = [
|
||||||
|
{ area: 'Achievement', grade: 'Strong', good: true },
|
||||||
|
{ area: 'Curriculum & teaching', grade: 'Expected standard', good: false },
|
||||||
|
{ area: 'Attendance & behaviour', grade: 'Strong', good: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +40,7 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.hiwPhaseBlock}>
|
<div className={styles.hiwPhaseBlock}>
|
||||||
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
||||||
<div className={styles.miniCascade}>
|
<div className={styles.miniCascade}>
|
||||||
{miniCascade.map(({ subj, exp, exc, nat }) => (
|
{miniCascade.map(({ subj, exp, exc, nat, excLabel }) => (
|
||||||
<div key={subj} className={styles.miniCascadeCol}>
|
<div key={subj} className={styles.miniCascadeCol}>
|
||||||
<div className={styles.miniSubj}>{subj}</div>
|
<div className={styles.miniSubj}>{subj}</div>
|
||||||
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
||||||
@@ -37,7 +48,7 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
||||||
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
|
<div className={styles.miniRowHead}><span>{excLabel}</span><strong>{exc}%</strong></div>
|
||||||
<div className={styles.miniTrack}>
|
<div className={styles.miniTrack}>
|
||||||
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -49,15 +60,15 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
||||||
<div className={styles.att8Row}>
|
<div className={styles.att8Row}>
|
||||||
<div className={styles.att8BarWrap}>
|
<div className={styles.att8BarWrap}>
|
||||||
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
|
<div className={styles.att8BarHead}><span>This school</span><span>National avg 45.9</span></div>
|
||||||
<div className={styles.att8Track}>
|
<div className={styles.att8Track}>
|
||||||
<div className={styles.att8Fill} style={{ width: '62%' }} />
|
<div className={styles.att8Fill} style={{ width: '54.8%' }} />
|
||||||
<div className={styles.att8NatLine} style={{ left: '50%' }} />
|
<div className={styles.att8NatLine} style={{ left: '45.9%' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.att8Score}>
|
<div className={styles.att8Score}>
|
||||||
<div className={styles.att8Value}>62.4</div>
|
<div className={styles.att8Value}>54.8</div>
|
||||||
<div className={styles.att8Delta}>+12.2 vs national</div>
|
<div className={styles.att8Delta}>+8.9 vs national</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,15 +88,26 @@ export function HowItWorksSection() {
|
|||||||
<span className={styles.ofstedBullet} />
|
<span className={styles.ofstedBullet} />
|
||||||
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.ofstedBadge}>OUTSTANDING</span>
|
<span className={styles.ofstedFramework}>Report card · five-point scale</span>
|
||||||
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
|
<div className={styles.rcList}>
|
||||||
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
|
{reportCard.map(({ area, grade, good }) => (
|
||||||
|
<div key={area} className={styles.rcRow}>
|
||||||
|
<span className={styles.rcArea}>{area}</span>
|
||||||
|
<span className={`${styles.rcChip} ${good ? styles.rcChipGood : styles.rcChipNeutral}`}>{grade}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.rcSafeguarding}>
|
||||||
|
<span className={styles.rcArea}>Safeguarding <span className={styles.rcSep}>· separate check</span></span>
|
||||||
|
<span className={`${styles.rcChip} ${styles.rcChipMet}`}>Met</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.ofstedMeta}>Full inspection · March 2026</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hiwCardBody}>
|
<div className={styles.hiwCardBody}>
|
||||||
<div className={styles.hiwStep}>Judgement</div>
|
<div className={styles.hiwStep}>Judgement</div>
|
||||||
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
||||||
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline — without opening a 40-page report.</p>
|
<p className={styles.hiwDesc}>Since November 2025, Ofsted rates each area of school life on a five-point scale rather than one overall word. We show every area and the inspection date — and, for schools not yet reinspected, the older overall grade they still carry.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ interface PopupBadge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPopupBadge(school: School): PopupBadge {
|
function buildPopupBadge(school: School): PopupBadge {
|
||||||
|
// A report card wins over any carried-forward legacy grade — its presence is
|
||||||
|
// signalled by ofsted_rc_date (the list has no full report_card object, and
|
||||||
|
// ofsted_framework is the raw event grouping, never "ReportCard").
|
||||||
|
if (school.ofsted_rc_date) {
|
||||||
|
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||||||
|
return { label: `Report Card · ${rcYear}`, style: 'background:#5a3a6e;color:#fff' };
|
||||||
|
}
|
||||||
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
||||||
const yearStr = year ? ` · ${year}` : '';
|
const yearStr = year ? ` · ${year}` : '';
|
||||||
if (school.ofsted_grade) {
|
if (school.ofsted_grade) {
|
||||||
@@ -55,9 +62,6 @@ function buildPopupBadge(school: School): PopupBadge {
|
|||||||
};
|
};
|
||||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||||||
}
|
}
|
||||||
if (school.ofsted_framework === 'ReportCard') {
|
|
||||||
return { label: `Report Card${yearStr}`, style: 'background:#5a3a6e;color:#fff' };
|
|
||||||
}
|
|
||||||
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
|
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,13 @@
|
|||||||
.modal {
|
.modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 95vh;
|
/* Bottom sheet sized against the overlay (which tracks the visual
|
||||||
|
viewport), NOT vh: when the keyboard is open the overlay is short, so
|
||||||
|
max-height:100% keeps the whole sheet — input and results — above the
|
||||||
|
keyboard. min-height gives a comfortable default without a tiny stub,
|
||||||
|
but is capped at 100% so it never exceeds the visible area. */
|
||||||
|
min-height: min(55vh, 100%);
|
||||||
|
max-height: 100%;
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
animation: slideUp 0.3s ease;
|
animation: slideUp 0.3s ease;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useCallback } from 'react';
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import styles from './Modal.module.css';
|
import styles from './Modal.module.css';
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ interface ModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
|
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -39,6 +41,32 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
|
|||||||
};
|
};
|
||||||
}, [isOpen, handleEscape]);
|
}, [isOpen, handleEscape]);
|
||||||
|
|
||||||
|
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
|
||||||
|
// the on-screen keyboard shrinks the visual viewport but not the layout one,
|
||||||
|
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
|
||||||
|
// bottom-anchored sheet (and the dim backdrop's lower half) hidden behind
|
||||||
|
// the keyboard. Tracking visualViewport.height/offsetTop keeps the whole
|
||||||
|
// overlay — backdrop and sheet — inside the visible area, above the keyboard.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||||
|
const el = overlayRef.current;
|
||||||
|
if (!vv || !el) return;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
el.style.top = `${vv.offsetTop}px`;
|
||||||
|
el.style.height = `${vv.height}px`;
|
||||||
|
el.style.bottom = 'auto';
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
vv.addEventListener('resize', sync);
|
||||||
|
vv.addEventListener('scroll', sync);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', sync);
|
||||||
|
vv.removeEventListener('scroll', sync);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen || typeof window === 'undefined') return null;
|
if (!isOpen || typeof window === 'undefined') return null;
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||||
@@ -48,7 +76,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={styles.overlay} onClick={handleOverlayClick}>
|
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
|
||||||
<div className={`${styles.modal} ${styles[size]}`}>
|
<div className={`${styles.modal} ${styles[size]}`}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{title && <h2 className={styles.title}>{title}</h2>}
|
{title && <h2 className={styles.title}>{title}</h2>}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ChartOptions, ChartDataset } from 'chart.js';
|
|||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
import type { SchoolResult } from '@/lib/types';
|
import type { SchoolResult } from '@/lib/types';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
|
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
import { track } from '@/lib/analytics';
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './PerformanceChart.module.css';
|
import styles from './PerformanceChart.module.css';
|
||||||
@@ -35,8 +36,6 @@ interface PerformanceChartProps {
|
|||||||
nationalByYear?: NationalByYear[];
|
nationalByYear?: NationalByYear[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const COVID_YEARS = new Set([201920, 202021]);
|
|
||||||
|
|
||||||
// Mobile chip definitions: which datasets render when each chip is active.
|
// Mobile chip definitions: which datasets render when each chip is active.
|
||||||
// `series` keys reference the dataset labels so we can filter cleanly.
|
// `series` keys reference the dataset labels so we can filter cleanly.
|
||||||
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
||||||
@@ -67,21 +66,33 @@ export function PerformanceChart({
|
|||||||
nationalByYear,
|
nationalByYear,
|
||||||
}: PerformanceChartProps) {
|
}: PerformanceChartProps) {
|
||||||
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
||||||
const years = sortedData.map(d => formatAcademicYear(d.year));
|
|
||||||
|
// Gap-honest year axis: every academic year between the first and last data
|
||||||
|
// point appears, so cancelled/unpublished years (2019/20, 2020/21, and — for
|
||||||
|
// KS2 — 2021/22) render as real gaps rather than compressed time. School
|
||||||
|
// lines never bridge these gaps (spanGaps:false below).
|
||||||
|
const axisYears = fillAcademicYears(sortedData.map(d => d.year));
|
||||||
|
const byYear = new Map(sortedData.map(d => [d.year, d]));
|
||||||
|
const col = (key: keyof SchoolResult): (number | null)[] =>
|
||||||
|
axisYears.map(y => {
|
||||||
|
const v = byYear.get(y)?.[key];
|
||||||
|
return typeof v === 'number' ? v : null;
|
||||||
|
});
|
||||||
|
const years = axisYears.map(formatAcademicYear);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// ── Build per-year national averages ─────────────────────────────────
|
// ── Build per-year national averages (aligned to the filled axis) ────
|
||||||
const natRefRwm: (number | null)[] = sortedData.map(d => {
|
const natRefRwm: (number | null)[] = axisYears.map(y => {
|
||||||
if (nationalByYear) {
|
if (nationalByYear) {
|
||||||
const match = nationalByYear.find(n => n.year === d.year);
|
const match = nationalByYear.find(n => n.year === y);
|
||||||
return match?.primary?.rwm_expected_pct ?? null;
|
return match?.primary?.rwm_expected_pct ?? null;
|
||||||
}
|
}
|
||||||
return nationalRwmAvg ?? null;
|
return nationalRwmAvg ?? null;
|
||||||
});
|
});
|
||||||
const natRefAtt8: (number | null)[] = sortedData.map(d => {
|
const natRefAtt8: (number | null)[] = axisYears.map(y => {
|
||||||
if (nationalByYear) {
|
if (nationalByYear) {
|
||||||
const match = nationalByYear.find(n => n.year === d.year);
|
const match = nationalByYear.find(n => n.year === y);
|
||||||
return match?.secondary?.attainment_8_score ?? null;
|
return match?.secondary?.attainment_8_score ?? null;
|
||||||
}
|
}
|
||||||
return nationalAtt8Avg ?? null;
|
return nationalAtt8Avg ?? null;
|
||||||
@@ -107,10 +118,11 @@ export function PerformanceChart({
|
|||||||
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const hasCovidGap = isSecondary
|
// A gap year is any filled axis year the school has no results row for —
|
||||||
? false
|
// exactly the cancelled/unpublished years the fill introduced. Drives the
|
||||||
: COVID_YEARS.size > 0 &&
|
// honest note below (KS2 and KS4 have different gap stories).
|
||||||
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
|
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||||||
|
const hasGap = gapYears.length > 0;
|
||||||
|
|
||||||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||||
const refLineStyle = {
|
const refLineStyle = {
|
||||||
@@ -120,13 +132,14 @@ export function PerformanceChart({
|
|||||||
borderDash: [6, 4] as number[],
|
borderDash: [6, 4] as number[],
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
tension: 0,
|
tension: 0,
|
||||||
|
spanGaps: false as const,
|
||||||
order: 10,
|
order: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
||||||
{
|
{
|
||||||
label: 'Attainment 8',
|
label: 'Attainment 8',
|
||||||
data: sortedData.map(d => d.attainment_8_score),
|
data: col('attainment_8_score'),
|
||||||
borderColor: '#2d7d7d',
|
borderColor: '#2d7d7d',
|
||||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
@@ -137,7 +150,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'English & Maths Grade 4+',
|
label: 'English & Maths Grade 4+',
|
||||||
data: sortedData.map(d => d.english_maths_standard_pass_pct),
|
data: col('english_maths_standard_pass_pct'),
|
||||||
borderColor: '#c9a227',
|
borderColor: '#c9a227',
|
||||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -147,7 +160,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Progress 8',
|
label: 'Progress 8',
|
||||||
data: sortedData.map(d => d.progress_8_score),
|
data: col('progress_8_score'),
|
||||||
borderColor: 'rgb(139,92,246)',
|
borderColor: 'rgb(139,92,246)',
|
||||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -165,7 +178,7 @@ export function PerformanceChart({
|
|||||||
] : [
|
] : [
|
||||||
{
|
{
|
||||||
label: 'Reading, Writing & Maths expected %',
|
label: 'Reading, Writing & Maths expected %',
|
||||||
data: sortedData.map(d => d.rwm_expected_pct),
|
data: col('rwm_expected_pct'),
|
||||||
borderColor: '#2d7d7d',
|
borderColor: '#2d7d7d',
|
||||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||||
borderWidth: 2.5,
|
borderWidth: 2.5,
|
||||||
@@ -176,7 +189,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Exceeding expected level',
|
label: 'Exceeding expected level',
|
||||||
data: sortedData.map(d => d.rwm_high_pct),
|
data: col('rwm_high_pct'),
|
||||||
borderColor: '#c9a227',
|
borderColor: '#c9a227',
|
||||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -192,7 +205,7 @@ export function PerformanceChart({
|
|||||||
} as ChartDataset<'line'>] : []),
|
} as ChartDataset<'line'>] : []),
|
||||||
{
|
{
|
||||||
label: 'Reading progress',
|
label: 'Reading progress',
|
||||||
data: sortedData.map(d => d.reading_progress),
|
data: col('reading_progress'),
|
||||||
borderColor: 'rgb(59,130,246)',
|
borderColor: 'rgb(59,130,246)',
|
||||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -203,7 +216,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Writing progress',
|
label: 'Writing progress',
|
||||||
data: sortedData.map(d => d.writing_progress),
|
data: col('writing_progress'),
|
||||||
borderColor: 'rgb(139,92,246)',
|
borderColor: 'rgb(139,92,246)',
|
||||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -214,7 +227,7 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Maths progress',
|
label: 'Maths progress',
|
||||||
data: sortedData.map(d => d.maths_progress),
|
data: col('maths_progress'),
|
||||||
borderColor: 'rgb(236,72,153)',
|
borderColor: 'rgb(236,72,153)',
|
||||||
backgroundColor: 'rgba(236,72,153,0.08)',
|
backgroundColor: 'rgba(236,72,153,0.08)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
@@ -275,6 +288,8 @@ export function PerformanceChart({
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
// Never bridge missing years — cancelled/unpublished years are real gaps.
|
||||||
|
datasets: { line: { spanGaps: false } },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'top',
|
position: 'top',
|
||||||
@@ -326,6 +341,7 @@ export function PerformanceChart({
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
datasets: { line: { spanGaps: false } },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
@@ -353,7 +369,10 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
ticks: { font: { size: 10 }, autoSkip: false },
|
// With the gap-honest axis (more year labels) autoSkip keeps the
|
||||||
|
// phone axis readable; the broken line still shows where a year is
|
||||||
|
// missing even when its tick label is skipped.
|
||||||
|
ticks: { font: { size: 10 }, autoSkip: true, maxTicksLimit: 5, maxRotation: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -416,9 +435,11 @@ export function PerformanceChart({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasCovidGap && (
|
{hasGap && (
|
||||||
<p className={styles.covidNote}>
|
<p className={styles.covidNote}>
|
||||||
* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.
|
{isSecondary
|
||||||
|
? "School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID grading) where the line breaks — the England average is shown where available."
|
||||||
|
: "No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish school-level figures for 2021/22 — the England average is shown for those years where available."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function RankingsView({
|
|||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>School Rankings</h1>
|
<h1>School Rankings</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Top-performing schools by {metricLabel.toLowerCase()}
|
Schools ranked by {metricLabel.toLowerCase()}
|
||||||
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -143,7 +143,10 @@ export function RankingsView({
|
|||||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||||
)}
|
)}
|
||||||
{isProgressScore && (
|
{isProgressScore && (
|
||||||
<p className={styles.progressHint}>Progress scores: 0 = national average. Positive = above average.</p>
|
<p className={styles.progressHint}>
|
||||||
|
Progress scores: 0 = national average. Positive = above average.
|
||||||
|
{isPrimary && ' KS2 progress isn’t published for 2023/24 or 2024/25 (there’s no key stage 1 baseline) — pick an earlier year to rank by it.'}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -218,7 +221,11 @@ export function RankingsView({
|
|||||||
{rankings.length === 0 ? (
|
{rankings.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No rankings found"
|
title="No rankings found"
|
||||||
message="Try selecting a different metric, area, or year."
|
message={
|
||||||
|
isPrimary && isProgressScore
|
||||||
|
? 'KS2 progress scores aren’t published for the most recent years (2023/24 and 2024/25) because there’s no key stage 1 baseline. Select an earlier year to see progress rankings.'
|
||||||
|
: 'Try selecting a different metric, area, or year.'
|
||||||
|
}
|
||||||
action={{
|
action={{
|
||||||
label: 'Clear filters',
|
label: 'Clear filters',
|
||||||
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
||||||
|
|||||||
@@ -44,30 +44,46 @@
|
|||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary, #f3ede4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── National average marker ── */
|
/* ── Per-bar national average marker ──
|
||||||
.natLine {
|
Each bar compares against its own benchmark (expected vs higher standard /
|
||||||
|
greater depth), so the marker sits on the individual bar's track rather than
|
||||||
|
as one line spanning both bars. */
|
||||||
|
.natTick {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: -3px;
|
||||||
height: calc(100% - 20px);
|
bottom: -3px;
|
||||||
width: 1.5px;
|
width: 2px;
|
||||||
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */
|
transform: translateX(-50%);
|
||||||
z-index: 2;
|
background: var(--accent-coral, #e07256);
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: 4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.natPill {
|
.natTick::before {
|
||||||
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -10px;
|
top: -3px;
|
||||||
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
background: var(--accent-coral, #e07256);
|
background: var(--accent-coral, #e07256);
|
||||||
color: #fff;
|
}
|
||||||
|
|
||||||
|
.barHeaderRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.natLabel {
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 0.1rem 0.35rem;
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
border-radius: 4px;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
letter-spacing: 0.03em;
|
||||||
z-index: 3;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bar rows ── */
|
/* ── Bar rows ── */
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ interface SubjectData {
|
|||||||
expectedPct: number | null;
|
expectedPct: number | null;
|
||||||
exceedingPct: number | null;
|
exceedingPct: number | null;
|
||||||
nationalExpectedPct: number | null;
|
nationalExpectedPct: number | null;
|
||||||
|
/** National higher-standard / greater-depth benchmark for the exceeding
|
||||||
|
* bar — a different figure from the expected national. */
|
||||||
|
nationalExceedingPct: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SatsChartProps {
|
interface SatsChartProps {
|
||||||
@@ -17,11 +20,45 @@ interface SatsChartProps {
|
|||||||
const RULER_TICKS = [0, 25, 50, 75, 100];
|
const RULER_TICKS = [0, 25, 50, 75, 100];
|
||||||
const GRIDLINE_POSITIONS = [25, 50, 75];
|
const GRIDLINE_POSITIONS = [25, 50, 75];
|
||||||
|
|
||||||
|
/** One results bar with a national marker positioned on its own track, so the
|
||||||
|
* expected and exceeding bars each compare against their own benchmark. */
|
||||||
|
function BarRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
national,
|
||||||
|
barRef,
|
||||||
|
barClass,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
national: number | null;
|
||||||
|
barRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
barClass: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.barRow}>
|
||||||
|
<div className={styles.barHeader}>
|
||||||
|
<span className={styles.barLabelSuffix}>{label}</span>
|
||||||
|
<span className={styles.barHeaderRight}>
|
||||||
|
{national != null && <span className={styles.natLabel}>nat {national.toFixed(0)}%</span>}
|
||||||
|
<span className={styles.barLabel}>{value.toFixed(0)}%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.barTrack}>
|
||||||
|
<div ref={barRef} className={`${styles.bar} ${barClass}`} data-width={value} />
|
||||||
|
{national != null && (
|
||||||
|
<div className={styles.natTick} style={{ left: `${national}%` }} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
||||||
const expectedRef = useRef<HTMLDivElement>(null);
|
const expectedRef = useRef<HTMLDivElement>(null);
|
||||||
const exceedingRef = useRef<HTMLDivElement>(null);
|
const exceedingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
|
const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
|
||||||
|
|
||||||
// Animate bars on mount
|
// Animate bars on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,44 +88,25 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* National average marker */}
|
{/* Bars — each carries its own national marker */}
|
||||||
{nationalExpectedPct != null && (
|
|
||||||
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
|
|
||||||
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bars */}
|
|
||||||
<div className={styles.barGroup}>
|
<div className={styles.barGroup}>
|
||||||
{expectedPct != null && (
|
{expectedPct != null && (
|
||||||
<div className={styles.barRow}>
|
<BarRow
|
||||||
<div className={styles.barHeader}>
|
label="Expected"
|
||||||
<span className={styles.barLabelSuffix}>Expected</span>
|
value={expectedPct}
|
||||||
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span>
|
national={nationalExpectedPct}
|
||||||
</div>
|
barRef={expectedRef}
|
||||||
<div className={styles.barTrack}>
|
barClass={styles.barExpected}
|
||||||
<div
|
|
||||||
ref={expectedRef}
|
|
||||||
className={`${styles.bar} ${styles.barExpected}`}
|
|
||||||
data-width={expectedPct}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{exceedingPct != null && (
|
{exceedingPct != null && (
|
||||||
<div className={styles.barRow}>
|
<BarRow
|
||||||
<div className={styles.barHeader}>
|
label="Exceeding"
|
||||||
<span className={styles.barLabelSuffix}>Exceeding</span>
|
value={exceedingPct}
|
||||||
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span>
|
national={nationalExceedingPct}
|
||||||
</div>
|
barRef={exceedingRef}
|
||||||
<div className={styles.barTrack}>
|
barClass={styles.barExceeding}
|
||||||
<div
|
|
||||||
ref={exceedingRef}
|
|
||||||
className={`${styles.bar} ${styles.barExceeding}`}
|
|
||||||
data-width={exceedingPct}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +153,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
|||||||
Exceeding / high score
|
Exceeding / high score
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||||
National average
|
National average
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -855,7 +855,10 @@
|
|||||||
/* Charts Section */
|
/* Charts Section */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 280px;
|
/* Taller on desktop so the trend lines have vertical room to separate
|
||||||
|
and read clearly. Mobile overrides this to height:auto below (the
|
||||||
|
max-width:768px query), so this only affects desktop. */
|
||||||
|
height: 380px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,6 +1043,41 @@
|
|||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Ofsted grade grids (Report Card + OEIF) ──
|
||||||
|
Uniform, vertically-aligned grade chips. Labels reserve two lines so
|
||||||
|
single- and double-line labels put their chips on the same baseline;
|
||||||
|
every chip (Met, Strong, Expected standard, …) shares one font size,
|
||||||
|
padding and min-height regardless of how many lines its text wraps to. */
|
||||||
|
.gradeGrid .metricCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.85rem 0.75rem;
|
||||||
|
}
|
||||||
|
.gradeGrid .metricLabel {
|
||||||
|
min-height: 2.6em;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.gradeGrid .metricValue {
|
||||||
|
margin-top: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 2.6em;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.ofstedDisclaimer {
|
.ofstedDisclaimer {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import type {
|
|||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose,
|
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||||
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
|
|
||||||
const PerformanceChart = dynamic(
|
const PerformanceChart = dynamic(
|
||||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||||
@@ -85,6 +87,10 @@ export function SchoolDetailView({
|
|||||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||||
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
||||||
|
// Banded interpretation of the first-choice offer rate ("More than half of
|
||||||
|
// first choices missed out" etc.) — the same banding the compare screen
|
||||||
|
// uses, so a low offer rate reads as how severe it actually is.
|
||||||
|
const admissionsSummary = summariseAdmissions(admissions);
|
||||||
// Only the section links scroll horizontally; Back and "All" stay pinned.
|
// Only the section links scroll horizontally; Back and "All" stay pinned.
|
||||||
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
|
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||||||
@@ -153,10 +159,16 @@ export function SchoolDetailView({
|
|||||||
|
|
||||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||||
|
|
||||||
// Phase detection
|
// Phase detection. All-through schools cover BOTH key stages, so they are
|
||||||
|
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
||||||
|
// have KS4 data) but isAllThrough gates the primary-only content (phonics,
|
||||||
|
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
||||||
const phase = schoolInfo.phase ?? '';
|
const phase = schoolInfo.phase ?? '';
|
||||||
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
|
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||||||
|
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
||||||
const isPrimary = !isSecondary;
|
const isPrimary = !isSecondary;
|
||||||
|
// Primary-stage content shows for pure-primary AND all-through schools.
|
||||||
|
const showPrimaryContent = isPrimary || isAllThrough;
|
||||||
|
|
||||||
// National averages (fetched dynamically so they stay current)
|
// National averages (fetched dynamically so they stay current)
|
||||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||||
@@ -222,17 +234,38 @@ export function SchoolDetailView({
|
|||||||
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
||||||
const hasAnyResults = hasKS2Results || hasKS4Results;
|
const hasAnyResults = hasKS2Results || hasKS4Results;
|
||||||
|
|
||||||
|
// Special schools / PRUs / AP: their pupils sit the same tests but very few
|
||||||
|
// reach the mainstream "expected standard", so a 0% headline and an England
|
||||||
|
// comparison portray them as failing against a benchmark that doesn't fit.
|
||||||
|
const isSpecial = isSpecialSchool(schoolInfo);
|
||||||
|
// Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a
|
||||||
|
// special/suppressed signature) is a placeholder, not a real result. This
|
||||||
|
// needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0%
|
||||||
|
// combined (some pupils met individual subjects but not all three) stays
|
||||||
|
// comparable. Attainment 8 is a single 0–80 score with no subject breakdown
|
||||||
|
// to form such a signature, so KS4 keys off establishment type only — a
|
||||||
|
// genuine (if extreme) 0.0 still shows its real figure and comparison.
|
||||||
|
const ks2Placeholder = latestResults != null
|
||||||
|
&& latestResults.rwm_expected_pct === 0
|
||||||
|
&& (latestResults.reading_expected_pct ?? 0) === 0
|
||||||
|
&& (latestResults.writing_expected_pct ?? 0) === 0
|
||||||
|
&& (latestResults.maths_expected_pct ?? 0) === 0;
|
||||||
|
// Whether to drop the England-average deltas / national markers / "below"
|
||||||
|
// framing on the attainment measures.
|
||||||
|
const suppressKs2Comparison = isSpecial || ks2Placeholder;
|
||||||
|
const suppressKs4Comparison = isSpecial;
|
||||||
|
|
||||||
// Build section nav items dynamically — only sections with data.
|
// Build section nav items dynamically — only sections with data.
|
||||||
// Order is engagement-led (from section_nav_used analytics): the most-sought
|
// Order is engagement-led (from section_nav_used analytics): the most-sought
|
||||||
// sections — results, admissions, inclusion, history — sit near the top,
|
// sections — results, admissions, inclusion, history — sit near the top,
|
||||||
// after the recognised Ofsted badge; low-demand context sections stay last.
|
// after the recognised Ofsted badge; low-demand context sections stay last.
|
||||||
const navItems: { id: string; label: string }[] = [];
|
const navItems: { id: string; label: string }[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' });
|
if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' });
|
||||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||||
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
|
if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' });
|
||||||
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||||
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
@@ -269,18 +302,28 @@ export function SchoolDetailView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [navItems.map(n => n.id).join(',')]);
|
}, [navItems.map(n => n.id).join(',')]);
|
||||||
|
|
||||||
|
// A report card is identified by the presence of report-card area
|
||||||
|
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||||||
|
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||||||
|
// the old `framework === 'ReportCard'` test never matched and report cards
|
||||||
|
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||||||
|
const isReportCard = !!(
|
||||||
|
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
||||||
|
);
|
||||||
|
// A report card is dated by its own inspection (rc_inspection_date); the
|
||||||
|
// legacy inspection_date belongs to an older inspection and must never
|
||||||
|
// date a report card (report cards exist only from Nov 2025).
|
||||||
|
const ofstedInspectedDate = isReportCard
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
|
||||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||||
const oeifAllSameGrade = (() => {
|
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||||
if (!ofsted || ofsted.framework === 'ReportCard') return false;
|
const oeifAllSameGrade =
|
||||||
const subs = [
|
!!ofsted &&
|
||||||
ofsted.quality_of_education,
|
!isReportCard &&
|
||||||
ofsted.behaviour_attitudes,
|
oeifAreas.length >= 3 &&
|
||||||
ofsted.personal_development,
|
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||||
ofsted.leadership_management,
|
|
||||||
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
|
|
||||||
].filter((v): v is number => v != null);
|
|
||||||
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Label shown in the mobile "section" menu button — the section in view.
|
// Label shown in the mobile "section" menu button — the section in view.
|
||||||
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
||||||
@@ -309,6 +352,9 @@ export function SchoolDetailView({
|
|||||||
{schoolInfo.school_type && (
|
{schoolInfo.school_type && (
|
||||||
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
||||||
)}
|
)}
|
||||||
|
{isAllThrough && (
|
||||||
|
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
||||||
|
)}
|
||||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||||
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||||||
)}
|
)}
|
||||||
@@ -507,10 +553,10 @@ export function SchoolDetailView({
|
|||||||
{ofsted && (
|
{ofsted && (
|
||||||
<section id="ofsted" className={styles.card}>
|
<section id="ofsted" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>
|
<h2 className={styles.sectionTitle}>
|
||||||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||||
{ofsted.inspection_date && (
|
{ofstedInspectedDate && (
|
||||||
<span className={styles.ofstedDate}>
|
<span className={styles.ofstedDate}>
|
||||||
Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
@@ -525,13 +571,13 @@ export function SchoolDetailView({
|
|||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{ofsted.framework === 'ReportCard' ? (
|
{isReportCard ? (
|
||||||
/* ── New Report Card layout ── */
|
/* ── New Report Card layout ── */
|
||||||
<>
|
<>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{ofsted.rc_safeguarding_met != null && (
|
{ofsted.rc_safeguarding_met != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Safeguarding</div>
|
<div className={styles.metricLabel}>Safeguarding</div>
|
||||||
@@ -568,23 +614,17 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
|
{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.'
|
||||||
|
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}
|
||||||
</p>
|
</p>
|
||||||
{oeifAllSameGrade ? (
|
{oeifAllSameGrade ? (
|
||||||
<p className={styles.ofstedAllSame}>
|
<p className={styles.ofstedAllSame}>
|
||||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{[
|
{oeifAreas.map(({ label, value }) => (
|
||||||
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
|
||||||
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
|
|
||||||
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
|
|
||||||
{ label: 'School Leadership', value: ofsted.leadership_management },
|
|
||||||
...(ofsted.early_years_provision != null
|
|
||||||
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
|
|
||||||
: []),
|
|
||||||
].map(({ label, value }) => value != null && (
|
|
||||||
<div key={label} className={styles.metricCard}>
|
<div key={label} className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
<div className={styles.metricLabel}>{label}</div>
|
||||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||||
@@ -603,17 +643,31 @@ export function SchoolDetailView({
|
|||||||
{hasAnyResults && latestResults && (
|
{hasAnyResults && latestResults && (
|
||||||
<section id="results" className={styles.card}>
|
<section id="results" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>
|
<h2 className={styles.sectionTitle}>
|
||||||
{isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
{isSecondary
|
{isSpecial
|
||||||
? 'GCSE results for Year 11 pupils. National averages shown for comparison.'
|
? (isSecondary
|
||||||
: 'End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.'}
|
? 'GCSE results for Year 11 pupils.'
|
||||||
|
: 'End-of-primary-school tests taken by Year 6 pupils.')
|
||||||
|
: isAllThrough
|
||||||
|
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
|
||||||
|
: isSecondary
|
||||||
|
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
|
||||||
|
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Explains up front why the England comparison is dropped below, so
|
||||||
|
a 0% headline never reads as a failing grade against a benchmark
|
||||||
|
that doesn't fit. Type-aware copy (special vs PRU vs AP). */}
|
||||||
|
<SpecialSchoolNote school={schoolInfo} />
|
||||||
|
|
||||||
{/* ── Primary / KS2 content ── */}
|
{/* ── Primary / KS2 content ── */}
|
||||||
{hasKS2Results && (
|
{hasKS2Results && (
|
||||||
<>
|
<>
|
||||||
|
{isAllThrough && (
|
||||||
|
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs (Year 6)</h3>
|
||||||
|
)}
|
||||||
<div className={styles.heroStatGrid}>
|
<div className={styles.heroStatGrid}>
|
||||||
{latestResults.rwm_expected_pct !== null && (
|
{latestResults.rwm_expected_pct !== null && (
|
||||||
<div className={styles.heroStatCard}>
|
<div className={styles.heroStatCard}>
|
||||||
@@ -623,7 +677,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.rwm_expected_pct)}
|
{formatPercentage(latestResults.rwm_expected_pct)}
|
||||||
{primaryAvg.rwm_expected_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.rwm_expected_pct}
|
value={latestResults.rwm_expected_pct}
|
||||||
baseline={primaryAvg.rwm_expected_pct}
|
baseline={primaryAvg.rwm_expected_pct}
|
||||||
@@ -632,8 +686,8 @@ export function SchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.rwm_expected_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -645,7 +699,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.rwm_high_pct)}
|
{formatPercentage(latestResults.rwm_high_pct)}
|
||||||
{primaryAvg.rwm_high_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.rwm_high_pct}
|
value={latestResults.rwm_high_pct}
|
||||||
baseline={primaryAvg.rwm_high_pct}
|
baseline={primaryAvg.rwm_high_pct}
|
||||||
@@ -654,14 +708,15 @@ export function SchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.rwm_high_pct != null && (
|
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{latestResults.rwm_expected_pct != null &&
|
{!suppressKs2Comparison &&
|
||||||
|
latestResults.rwm_expected_pct != null &&
|
||||||
latestResults.reading_expected_pct != null &&
|
latestResults.reading_expected_pct != null &&
|
||||||
latestResults.writing_expected_pct != null &&
|
latestResults.writing_expected_pct != null &&
|
||||||
latestResults.maths_expected_pct != null && (
|
latestResults.maths_expected_pct != null && (
|
||||||
@@ -685,28 +740,38 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* All-zero placeholder rows (special / suppressed) would render as
|
||||||
|
three empty bars against the national markers — misleading, so
|
||||||
|
skip the chart. For a special school with some non-zero
|
||||||
|
subjects, keep the bars but drop the national markers. */}
|
||||||
|
{!ks2Placeholder && (
|
||||||
<SatsChart
|
<SatsChart
|
||||||
subjects={[
|
subjects={[
|
||||||
{
|
{
|
||||||
name: 'Reading',
|
name: 'Reading',
|
||||||
expectedPct: latestResults.reading_expected_pct,
|
expectedPct: latestResults.reading_expected_pct,
|
||||||
exceedingPct: latestResults.reading_high_pct,
|
exceedingPct: latestResults.reading_high_pct,
|
||||||
nationalExpectedPct: primaryAvg.reading_expected_pct,
|
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
|
||||||
|
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Writing',
|
name: 'Writing',
|
||||||
expectedPct: latestResults.writing_expected_pct,
|
expectedPct: latestResults.writing_expected_pct,
|
||||||
exceedingPct: latestResults.writing_high_pct,
|
exceedingPct: latestResults.writing_high_pct,
|
||||||
nationalExpectedPct: primaryAvg.writing_expected_pct,
|
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
|
||||||
|
// Writing's higher level is teacher-assessed "greater depth".
|
||||||
|
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Maths',
|
name: 'Maths',
|
||||||
expectedPct: latestResults.maths_expected_pct,
|
expectedPct: latestResults.maths_expected_pct,
|
||||||
exceedingPct: latestResults.maths_high_pct,
|
exceedingPct: latestResults.maths_high_pct,
|
||||||
nationalExpectedPct: primaryAvg.maths_expected_pct,
|
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
|
||||||
|
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress scores row */}
|
{/* Progress scores row */}
|
||||||
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
||||||
@@ -752,6 +817,9 @@ export function SchoolDetailView({
|
|||||||
{/* ── Secondary / KS4 content ── */}
|
{/* ── Secondary / KS4 content ── */}
|
||||||
{hasKS4Results && (
|
{hasKS4Results && (
|
||||||
<>
|
<>
|
||||||
|
{isAllThrough && (
|
||||||
|
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs (Year 11)</h3>
|
||||||
|
)}
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{latestResults.attainment_8_score !== null && (
|
{latestResults.attainment_8_score !== null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
@@ -760,8 +828,8 @@ export function SchoolDetailView({
|
|||||||
<MetricTooltip metricKey="attainment_8_score" />
|
<MetricTooltip metricKey="attainment_8_score" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
|
||||||
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -784,8 +852,8 @@ export function SchoolDetailView({
|
|||||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -796,8 +864,8 @@ export function SchoolDetailView({
|
|||||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -861,6 +929,15 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* All-through admissions data covers a single entry point (usually the
|
||||||
|
Year 7 secondary intake), not reception — say so, or a parent could
|
||||||
|
read these as the whole-school figures. */}
|
||||||
|
{isAllThrough && admissions.school_phase && (
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
These figures are for {admissions.school_phase.toLowerCase()} entry
|
||||||
|
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.admissionsViewport}>
|
<div className={styles.admissionsViewport}>
|
||||||
{/* This-year Q&A */}
|
{/* This-year Q&A */}
|
||||||
@@ -902,6 +979,9 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
{admissionsSummary.chip && (
|
||||||
|
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multi-year trend */}
|
{/* Multi-year trend */}
|
||||||
@@ -937,7 +1017,7 @@ export function SchoolDetailView({
|
|||||||
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults?.eal_pct != null && (
|
{latestResults?.eal_pct != null && (
|
||||||
@@ -953,7 +1033,7 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.eal_pct != null && (
|
{primaryAvg.eal_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -970,7 +1050,7 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.sen_support_pct != null && (
|
{primaryAvg.sen_support_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1034,16 +1114,52 @@ export function SchoolDetailView({
|
|||||||
{yearlyData.length > 0 && (
|
{yearlyData.length > 0 && (
|
||||||
<section id="history" className={styles.card}>
|
<section id="history" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
||||||
|
{isAllThrough ? (
|
||||||
|
// All-through: KS2 and KS4 trends are on different scales and have
|
||||||
|
// different gap stories, so render them as two stacked charts
|
||||||
|
// rather than crowding 8+ series onto one axis.
|
||||||
|
<>
|
||||||
|
{hasKS2Results && (
|
||||||
|
<>
|
||||||
|
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs</h3>
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<PerformanceChart
|
||||||
|
data={yearlyData}
|
||||||
|
schoolName={schoolInfo.school_name}
|
||||||
|
isSecondary={false}
|
||||||
|
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
|
||||||
|
nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasKS4Results && (
|
||||||
|
<>
|
||||||
|
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs</h3>
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<PerformanceChart
|
||||||
|
data={yearlyData}
|
||||||
|
schoolName={schoolInfo.school_name}
|
||||||
|
isSecondary={true}
|
||||||
|
nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
|
||||||
|
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className={styles.chartContainer}>
|
<div className={styles.chartContainer}>
|
||||||
<PerformanceChart
|
<PerformanceChart
|
||||||
data={yearlyData}
|
data={yearlyData}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
isSecondary={isSecondary}
|
isSecondary={isSecondary}
|
||||||
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
||||||
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
|
nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
|
||||||
nationalByYear={nationalAvg?.by_year}
|
nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{yearlyData.length > 1 && (
|
{yearlyData.length > 1 && (
|
||||||
<details className={styles.historyDisclosure}>
|
<details className={styles.historyDisclosure}>
|
||||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||||
@@ -1052,7 +1168,15 @@ export function SchoolDetailView({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Year</th>
|
<th>Year</th>
|
||||||
{isSecondary ? (
|
{isAllThrough ? (
|
||||||
|
<>
|
||||||
|
<th>RWM (expected %)</th>
|
||||||
|
<th>Exceeding (%)</th>
|
||||||
|
<th>Attainment 8</th>
|
||||||
|
<th>Progress 8</th>
|
||||||
|
<th>English & Maths Grade 4+</th>
|
||||||
|
</>
|
||||||
|
) : isSecondary ? (
|
||||||
<>
|
<>
|
||||||
<th>Attainment 8</th>
|
<th>Attainment 8</th>
|
||||||
<th>Progress 8</th>
|
<th>Progress 8</th>
|
||||||
@@ -1074,7 +1198,15 @@ export function SchoolDetailView({
|
|||||||
{yearlyData.map((result) => (
|
{yearlyData.map((result) => (
|
||||||
<tr key={result.year}>
|
<tr key={result.year}>
|
||||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||||
{isSecondary ? (
|
{isAllThrough ? (
|
||||||
|
<>
|
||||||
|
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
|
||||||
|
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
|
||||||
|
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||||
|
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||||
|
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||||
|
</>
|
||||||
|
) : isSecondary ? (
|
||||||
<>
|
<>
|
||||||
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||||
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||||
@@ -1099,8 +1231,8 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{/* Year 1 Phonics — primary only */}
|
{/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
|
||||||
{hasPhonics && isPrimary && phonics && (
|
{hasPhonics && showPrimaryContent && phonics && (
|
||||||
<section id="phonics" className={styles.card}>
|
<section id="phonics" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
@@ -1142,7 +1274,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
|
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
|
||||||
{primaryAvg.overall_absence_pct != null && (
|
{primaryAvg.overall_absence_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
|
<div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1154,7 +1286,7 @@ export function SchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
|
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
|
||||||
{primaryAvg.persistent_absence_pct != null && (
|
{primaryAvg.persistent_absence_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||||||
import styles from './SchoolRow.module.css';
|
import styles from './SchoolRow.module.css';
|
||||||
|
|
||||||
interface SchoolRowProps {
|
interface SchoolRowProps {
|
||||||
@@ -38,10 +38,23 @@ export function SchoolRow({
|
|||||||
school.religious_denomination &&
|
school.religious_denomination &&
|
||||||
school.religious_denomination !== 'Does not apply';
|
school.religious_denomination !== 'Does not apply';
|
||||||
|
|
||||||
// vs-national delta
|
// A placeholder all-zero row (every subject 0 — a special/suppressed
|
||||||
|
// signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
|
||||||
|
// so its figure is hidden. A genuine 0% combined (some pupils met individual
|
||||||
|
// subjects but not all three) is NOT all-zero and stays shown.
|
||||||
|
const rwmPlaceholder =
|
||||||
|
school.rwm_expected_pct === 0 &&
|
||||||
|
(school.reading_expected_pct ?? 0) === 0 &&
|
||||||
|
(school.writing_expected_pct ?? 0) === 0 &&
|
||||||
|
(school.maths_expected_pct ?? 0) === 0;
|
||||||
|
// The school's OWN figure and its year-over-year trend are same-school
|
||||||
|
// measures — shown whenever there's a real value, special schools included.
|
||||||
|
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
|
||||||
|
// The vs-England delta is a mainstream benchmark: additionally dropped for
|
||||||
|
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
||||||
const rwmDelta =
|
const rwmDelta =
|
||||||
school.rwm_expected_pct != null && nationalAvgRwm != null
|
showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null
|
||||||
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleCompareClick = () => {
|
const handleCompareClick = () => {
|
||||||
@@ -87,9 +100,9 @@ export function SchoolRow({
|
|||||||
<div className={styles.line3}>
|
<div className={styles.line3}>
|
||||||
<span className={styles.stat}>
|
<span className={styles.stat}>
|
||||||
<strong className={styles.statValue}>
|
<strong className={styles.statValue}>
|
||||||
{school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
|
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
|
||||||
</strong>
|
</strong>
|
||||||
{school.prev_rwm_expected_pct != null && (
|
{showRwmValue && school.prev_rwm_expected_pct != null && (
|
||||||
<span
|
<span
|
||||||
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
|
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
|
||||||
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
|
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
|
||||||
|
|||||||
@@ -2,14 +2,6 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
background: var(--accent-gold-bg);
|
background: var(--accent-gold-bg);
|
||||||
border: 1px solid var(--accent-gold, #c9a227);
|
border: 1px solid var(--accent-gold, #c9a227);
|
||||||
@@ -119,12 +111,16 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.25rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resultButton {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.schoolMeta {
|
.schoolMeta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -159,21 +155,30 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
/* One scroll container on mobile: the modal content itself scrolls, so the
|
||||||
font-size: 1.25rem;
|
results list must not add its own inner scroll (double scrollbars, and
|
||||||
|
the input would be trapped above a short 400px window when the keyboard
|
||||||
|
shrinks the sheet). */
|
||||||
|
.results {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact stacked card: name + meta, then a full-width action so the tap
|
||||||
|
target is obvious and the card doesn't waste vertical space. */
|
||||||
.resultItem {
|
.resultItem {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addButton {
|
.resultButton {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolMeta {
|
.schoolMeta {
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,8 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
<Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
<h2 className={styles.title}>Add School to Comparison</h2>
|
|
||||||
|
|
||||||
{!canAddMore && (
|
{!canAddMore && (
|
||||||
<div className={styles.warning}>
|
<div className={styles.warning}>
|
||||||
Maximum 5 schools can be compared. Remove a school to add another.
|
Maximum 5 schools can be compared. Remove a school to add another.
|
||||||
@@ -129,9 +127,9 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleAddSchool(school)}
|
onClick={() => handleAddSchool(school)}
|
||||||
disabled={alreadySelected || !canAddMore}
|
disabled={alreadySelected || !canAddMore}
|
||||||
className={
|
className={`${styles.resultButton} ${
|
||||||
alreadySelected ? "btn btn-active" : "btn btn-secondary"
|
alreadySelected ? "btn btn-active" : "btn btn-secondary"
|
||||||
}
|
}`}
|
||||||
>
|
>
|
||||||
{alreadySelected ? "✓ Comparing" : "+ Compare"}
|
{alreadySelected ? "✓ Comparing" : "+ Compare"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -490,7 +490,10 @@
|
|||||||
/* ── Charts & Map ────────────────────────────────────── */
|
/* ── Charts & Map ────────────────────────────────────── */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 280px;
|
/* Taller on desktop so the trend lines have vertical room to separate
|
||||||
|
and read clearly. Mobile overrides this to height:auto below (the
|
||||||
|
max-width:768px query), so this only affects desktop. */
|
||||||
|
height: 380px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +626,41 @@
|
|||||||
color: var(--accent-coral-dark, #b04a2e);
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Ofsted grade grids (Report Card + OEIF) ──
|
||||||
|
Uniform, vertically-aligned grade chips. Labels reserve two lines so
|
||||||
|
single- and double-line labels put their chips on the same baseline;
|
||||||
|
every chip (Met, Strong, Expected standard, …) shares one font size,
|
||||||
|
padding and min-height regardless of how many lines its text wraps to. */
|
||||||
|
.gradeGrid .metricCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.85rem 0.75rem;
|
||||||
|
}
|
||||||
|
.gradeGrid .metricLabel {
|
||||||
|
min-height: 2.6em;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.gradeGrid .metricValue {
|
||||||
|
margin-top: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 2.6em;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.ofstedDisclaimer {
|
.ofstedDisclaimer {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
@@ -1049,8 +1087,12 @@
|
|||||||
font-size: 1.85rem;
|
font-size: 1.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On mobile let the chart container flow naturally — PerformanceChart's
|
||||||
|
own .chartWrapper carries the definite canvas height (220px) plus the
|
||||||
|
chip strip above it. A fixed 220px here double-constrained the two and
|
||||||
|
clipped the chips onto the plot area. */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
height: 220px;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataTable {
|
.dataTable {
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ import type {
|
|||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||||
import { track, getNavigationSource } from '@/lib/analytics';
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SecondarySchoolDetailView.module.css';
|
import styles from './SecondarySchoolDetailView.module.css';
|
||||||
|
|
||||||
@@ -107,6 +108,16 @@ export function SecondarySchoolDetailView({
|
|||||||
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
||||||
const hasResults = latestResults?.attainment_8_score != null;
|
const hasResults = latestResults?.attainment_8_score != null;
|
||||||
|
|
||||||
|
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
|
||||||
|
// so their headline attainment is far below the mainstream average by design.
|
||||||
|
// Drop the England comparison + "below" framing so the page doesn't portray
|
||||||
|
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
|
||||||
|
// single 0–80 score with no subject breakdown to test for a placeholder, so
|
||||||
|
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
|
||||||
|
// mainstream school still shows its real value and comparison.
|
||||||
|
const isSpecial = isSpecialSchool(schoolInfo);
|
||||||
|
const suppressComparison = isSpecial;
|
||||||
|
|
||||||
const admissionsTag = (() => {
|
const admissionsTag = (() => {
|
||||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||||
if (policy.includes('selective')) return 'Selective';
|
if (policy.includes('selective')) return 'Selective';
|
||||||
@@ -186,18 +197,27 @@ export function SecondarySchoolDetailView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [navItems.map(n => n.id).join(',')]);
|
}, [navItems.map(n => n.id).join(',')]);
|
||||||
|
|
||||||
|
// A report card is identified by the presence of report-card area
|
||||||
|
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||||||
|
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||||||
|
// the old `framework === 'ReportCard'` test never matched and report cards
|
||||||
|
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||||||
|
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).
|
||||||
|
const ofstedInspectedDate = isReportCard
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
|
||||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||||
const oeifAllSameGrade = (() => {
|
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||||
if (!ofsted || ofsted.framework === 'ReportCard') return false;
|
const oeifAllSameGrade =
|
||||||
const subs = [
|
!!ofsted &&
|
||||||
ofsted.quality_of_education,
|
!isReportCard &&
|
||||||
ofsted.behaviour_attitudes,
|
oeifAreas.length >= 3 &&
|
||||||
ofsted.personal_development,
|
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||||
ofsted.leadership_management,
|
|
||||||
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
|
|
||||||
].filter((v): v is number => v != null);
|
|
||||||
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// National Attainment 8 baseline for the "Results Over Time" chart.
|
// National Attainment 8 baseline for the "Results Over Time" chart.
|
||||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||||
@@ -332,10 +352,10 @@ export function SecondarySchoolDetailView({
|
|||||||
{ofsted && (
|
{ofsted && (
|
||||||
<section id="ofsted" className={styles.card}>
|
<section id="ofsted" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>
|
<h2 className={styles.sectionTitle}>
|
||||||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||||
{ofsted.inspection_date && (
|
{ofstedInspectedDate && (
|
||||||
<span className={styles.ofstedDate}>
|
<span className={styles.ofstedDate}>
|
||||||
{' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
{' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
@@ -349,12 +369,12 @@ export function SecondarySchoolDetailView({
|
|||||||
Ofsted reports ↗
|
Ofsted reports ↗
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
{ofsted.framework === 'ReportCard' ? (
|
{isReportCard ? (
|
||||||
<>
|
<>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{ofsted.rc_safeguarding_met != null && (
|
{ofsted.rc_safeguarding_met != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Safeguarding</div>
|
<div className={styles.metricLabel}>Safeguarding</div>
|
||||||
@@ -390,23 +410,17 @@ export function SecondarySchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
|
{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.'
|
||||||
|
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'}
|
||||||
</p>
|
</p>
|
||||||
{oeifAllSameGrade ? (
|
{oeifAllSameGrade ? (
|
||||||
<p className={styles.ofstedAllSame}>
|
<p className={styles.ofstedAllSame}>
|
||||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{[
|
{oeifAreas.map(({ label, value }) => (
|
||||||
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
|
||||||
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
|
|
||||||
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
|
|
||||||
{ label: 'School Leadership', value: ofsted.leadership_management },
|
|
||||||
...(ofsted.early_years_provision != null
|
|
||||||
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
|
|
||||||
: []),
|
|
||||||
].map(({ label, value }) => value != null && (
|
|
||||||
<div key={label} className={styles.metricCard}>
|
<div key={label} className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
<div className={styles.metricLabel}>{label}</div>
|
||||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||||
@@ -422,7 +436,7 @@ export function SecondarySchoolDetailView({
|
|||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
From September 2024, Ofsted no longer gives a single overall grade.
|
From September 2024, Ofsted no longer gives a single overall grade.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
||||||
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
||||||
@@ -449,12 +463,15 @@ export function SecondarySchoolDetailView({
|
|||||||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
GCSE results for Year 11 pupils. National averages shown for comparison.
|
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SpecialSchoolNote school={schoolInfo} />
|
||||||
|
|
||||||
{p8Suspended && (
|
{p8Suspended && (
|
||||||
<div className={styles.p8Banner}>
|
<div className={styles.p8Banner}>
|
||||||
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
|
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
||||||
|
(COVID), so DfE has no starting point to measure their progress from.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -468,7 +485,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{latestResults.attainment_8_score.toFixed(1)}
|
{latestResults.attainment_8_score.toFixed(1)}
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.attainment_8_score}
|
value={latestResults.attainment_8_score}
|
||||||
baseline={secondaryAvg.attainment_8_score}
|
baseline={secondaryAvg.attainment_8_score}
|
||||||
@@ -477,8 +494,8 @@ export function SecondarySchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -508,7 +525,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.english_maths_strong_pass_pct}
|
value={latestResults.english_maths_strong_pass_pct}
|
||||||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||||||
@@ -517,8 +534,8 @@ export function SecondarySchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -530,7 +547,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.heroStatValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
value={latestResults.english_maths_standard_pass_pct}
|
value={latestResults.english_maths_standard_pass_pct}
|
||||||
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
||||||
@@ -539,15 +556,17 @@ export function SecondarySchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attainment 8 visual bar (0–80 scale) */}
|
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
|
||||||
{latestResults.attainment_8_score != null && (
|
"school vs national", so it's dropped for special schools where
|
||||||
|
that comparison isn't meaningful. */}
|
||||||
|
{!suppressComparison && latestResults.attainment_8_score != null && (
|
||||||
<div className={styles.att8Viz}>
|
<div className={styles.att8Viz}>
|
||||||
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
||||||
<div className={styles.att8VizTrack}>
|
<div className={styles.att8VizTrack}>
|
||||||
@@ -739,8 +758,8 @@ export function SecondarySchoolDetailView({
|
|||||||
data={yearlyData}
|
data={yearlyData}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
isSecondary={true}
|
isSecondary={true}
|
||||||
nationalAtt8Avg={heroAtt8Nat}
|
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
|
||||||
nationalByYear={nationalAvg?.by_year}
|
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||||||
import styles from './SecondarySchoolRow.module.css';
|
import styles from './SecondarySchoolRow.module.css';
|
||||||
|
|
||||||
function detectAdmissionsTag(school: School): string | null {
|
function detectAdmissionsTag(school: School): string | null {
|
||||||
@@ -55,8 +55,14 @@ export function SecondarySchoolRow({
|
|||||||
const ofstedBadge = buildOfstedListBadge(school);
|
const ofstedBadge = buildOfstedListBadge(school);
|
||||||
const phase = getPhaseStyle(school.phase);
|
const phase = getPhaseStyle(school.phase);
|
||||||
const att8 = school.attainment_8_score;
|
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.
|
||||||
const laDelta =
|
const laDelta =
|
||||||
att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
|
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
|
||||||
|
? att8 - laAvgAttainment8
|
||||||
|
: null;
|
||||||
|
|
||||||
const admissionsTag = detectAdmissionsTag(school);
|
const admissionsTag = detectAdmissionsTag(school);
|
||||||
const sixthForm = hasSixthForm(school);
|
const sixthForm = hasSixthForm(school);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* Special-school / PRU / AP context note: soft, informational (teal), not a
|
||||||
|
warning. Shared by both detail views so the styling can't drift. */
|
||||||
|
.note {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-left: 4px solid var(--accent-teal, #2d7d7d);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
}
|
||||||
|
.note strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* SpecialSchoolNote — the context note shown on special-school / PRU / AP
|
||||||
|
* detail pages explaining why the mainstream England-average comparison is
|
||||||
|
* dropped. Renders nothing for mainstream schools.
|
||||||
|
*
|
||||||
|
* The copy is type-aware: only genuine special schools have pupils with special
|
||||||
|
* educational needs. Pupil referral units and alternative provision teach
|
||||||
|
* pupils educated outside a mainstream setting (e.g. after exclusion, or for
|
||||||
|
* medical reasons) who are not necessarily SEND — so their note says so rather
|
||||||
|
* than mischaracterising them. Suppressing the England comparison is reasonable
|
||||||
|
* for all three.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isSpecialSchool } from '@/lib/utils';
|
||||||
|
import styles from './SpecialSchoolNote.module.css';
|
||||||
|
|
||||||
|
type SpecialKind = 'special' | 'pru' | 'ap';
|
||||||
|
|
||||||
|
function specialKind(schoolType: string | null | undefined): SpecialKind {
|
||||||
|
const t = (schoolType ?? '').toLowerCase();
|
||||||
|
if (/pupil referral/.test(t)) return 'pru';
|
||||||
|
if (/alternative provision/.test(t)) return 'ap';
|
||||||
|
return 'special';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecialSchoolNote({ school }: { school: { school_type?: string | null } }) {
|
||||||
|
if (!isSpecialSchool(school)) return null;
|
||||||
|
const kind = specialKind(school.school_type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.note} role="note">
|
||||||
|
{kind === 'special' && (
|
||||||
|
<>
|
||||||
|
<strong>This is a special school.</strong> Its pupils have special educational needs and
|
||||||
|
work towards individual targets. They sit the same national assessments, but very few
|
||||||
|
reach the mainstream “expected standard” these measures report — so a
|
||||||
|
comparison with the England average isn’t a meaningful guide to the school.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === 'pru' && (
|
||||||
|
<>
|
||||||
|
<strong>This is a pupil referral unit.</strong> It teaches pupils educated outside a
|
||||||
|
mainstream school — for example after exclusion, or for medical or behavioural reasons.
|
||||||
|
The mainstream “expected standard” and the England-average comparison
|
||||||
|
aren’t a meaningful guide to the school.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === 'ap' && (
|
||||||
|
<>
|
||||||
|
<strong>This is an alternative provision setting.</strong> It teaches pupils educated
|
||||||
|
outside a mainstream school. The mainstream “expected standard” and the
|
||||||
|
England-average comparison aren’t a meaningful guide to the school.
|
||||||
|
</>
|
||||||
|
)}{' '}
|
||||||
|
Where available, the progress its pupils make is a fairer measure.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { latestValues, verdict } from '@/lib/compareLogic';
|
import { latestValues, verdict } from '@/lib/compareLogic';
|
||||||
|
import { isSpecialSchool } from '@/lib/utils';
|
||||||
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||||
import { DotStrip } from '@/components/DotStrip';
|
import { DotStrip } from '@/components/DotStrip';
|
||||||
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||||
@@ -90,15 +91,19 @@ function Strip({
|
|||||||
urns,
|
urns,
|
||||||
schoolNames,
|
schoolNames,
|
||||||
national,
|
national,
|
||||||
|
special,
|
||||||
}: {
|
}: {
|
||||||
spec: StripSpec;
|
spec: StripSpec;
|
||||||
data: Record<string, ComparisonData>;
|
data: Record<string, ComparisonData>;
|
||||||
urns: number[];
|
urns: number[];
|
||||||
schoolNames: string[];
|
schoolNames: string[];
|
||||||
national: Record<string, number> | undefined;
|
national: Record<string, number> | undefined;
|
||||||
|
/** Per-school special-school flag; special schools' mainstream attainment is
|
||||||
|
* not a fair comparison, so it's dropped from the strip (no dot). */
|
||||||
|
special: boolean[];
|
||||||
}) {
|
}) {
|
||||||
const values = latestValues(data, urns, spec.metric).map((v) =>
|
const values = latestValues(data, urns, spec.metric).map((v, i) =>
|
||||||
v != null ? Math.round(v) : null,
|
v != null && !special[i] ? Math.round(v) : null,
|
||||||
);
|
);
|
||||||
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
|
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
|
||||||
const anchor =
|
const anchor =
|
||||||
@@ -135,12 +140,19 @@ export function CompareAcademics({
|
|||||||
}) {
|
}) {
|
||||||
const urns = schools.map((school) => school.urn);
|
const urns = schools.map((school) => school.urn);
|
||||||
const schoolNames = schools.map((school) => school.school_name);
|
const schoolNames = schools.map((school) => school.school_name);
|
||||||
|
// Special schools / PRUs / AP: their pupils sit the same assessments but very
|
||||||
|
// few reach the mainstream standard, so their attainment isn't a fair
|
||||||
|
// like-for-like comparison — drop it (progress banding, which IS meaningful,
|
||||||
|
// is kept).
|
||||||
|
const specialFlags = schools.map((school) => isSpecialSchool(school));
|
||||||
|
const dropSpecial = (vals: Array<number | null>) =>
|
||||||
|
vals.map((v, i) => (specialFlags[i] ? null : v));
|
||||||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSecondary) {
|
if (isSecondary) {
|
||||||
const att8 = latestValues(data, urns, 'attainment_8_score');
|
const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score'));
|
||||||
const banding = urns.map((urn) => {
|
const banding = urns.map((urn) => {
|
||||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||||
for (let i = rows.length - 1; i >= 0; i--) {
|
for (let i = rows.length - 1; i >= 0; i--) {
|
||||||
@@ -148,9 +160,35 @@ export function CompareAcademics({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
|
// DfE stopped publishing Progress 8 from 2024/25: those GCSE year groups
|
||||||
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
|
// sat no KS2 tests (COVID), so there is no baseline to measure progress
|
||||||
|
// from. A bare "No data" reads as a gap on our side — say why. Judged
|
||||||
|
// PER SCHOOL on its own latest data year: a school whose data simply
|
||||||
|
// stops earlier (an unrelated gap) must not borrow the COVID explanation
|
||||||
|
// from a neighbour that does have 2024/25 data.
|
||||||
|
const p8NotPublished = urns.map((urn) => {
|
||||||
|
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||||
|
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
|
||||||
|
return y >= 202425;
|
||||||
|
});
|
||||||
|
const grade5 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct'));
|
||||||
|
const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct'));
|
||||||
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
||||||
|
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
|
||||||
|
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
|
||||||
|
|
||||||
|
// Every headline number gets its England anchor + verdict chip, so the
|
||||||
|
// "anchored against the England average" promise holds for the grade-5
|
||||||
|
// and EBacc rows too, not just Attainment 8.
|
||||||
|
const anchorChip = (value: number | null, anchor: number | null | undefined, tol: number) => {
|
||||||
|
if (value == null || anchor == null) return null;
|
||||||
|
const v = verdict(value, anchor, tol);
|
||||||
|
return (
|
||||||
|
<Chip tone={v === 'above' ? 'good' : v === 'below' ? 'warn' : 'neutral'}>
|
||||||
|
{v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
@@ -163,7 +201,8 @@ export function CompareAcademics({
|
|||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{att8[i] != null ? (
|
{att8[i] != null ? (
|
||||||
<>
|
<>
|
||||||
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>
|
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>{' '}
|
||||||
|
{anchorChip(att8[i], att8Anchor, 2)}
|
||||||
{att8Anchor != null && (
|
{att8Anchor != null && (
|
||||||
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
||||||
)}
|
)}
|
||||||
@@ -189,6 +228,11 @@ export function CompareAcademics({
|
|||||||
>
|
>
|
||||||
{banding[i]}
|
{banding[i]}
|
||||||
</Chip>
|
</Chip>
|
||||||
|
) : p8NotPublished[i] ? (
|
||||||
|
<span className={s.small}>
|
||||||
|
Not published — this GCSE year group sat no KS2 tests (COVID), so DfE has no
|
||||||
|
baseline to measure progress from
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className={s.small}>No data</span>
|
<span className={s.small}>No data</span>
|
||||||
)}
|
)}
|
||||||
@@ -200,14 +244,38 @@ export function CompareAcademics({
|
|||||||
</RowLabel>
|
</RowLabel>
|
||||||
{schools.map((school, i) => (
|
{schools.map((school, i) => (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>}
|
{grade5[i] != null ? (
|
||||||
|
<>
|
||||||
|
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||||
|
{Math.round(grade5[i] as number)}%
|
||||||
|
</span>{' '}
|
||||||
|
{anchorChip(grade5[i], grade5Anchor, 3)}
|
||||||
|
{grade5Anchor != null && (
|
||||||
|
<span className={s.small}>England average {Math.round(grade5Anchor)}%</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={s.small}>No data</span>
|
||||||
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
||||||
{schools.map((school, i) => (
|
{schools.map((school, i) => (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>}
|
{ebacc[i] != null ? (
|
||||||
|
<>
|
||||||
|
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||||
|
{Math.round(ebacc[i] as number)}%
|
||||||
|
</span>{' '}
|
||||||
|
{anchorChip(ebacc[i], ebaccAnchor, 3)}
|
||||||
|
{ebaccAnchor != null && (
|
||||||
|
<span className={s.small}>England average {Math.round(ebaccAnchor)}%</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={s.small}>No data</span>
|
||||||
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
))}
|
))}
|
||||||
</SectionGrid>
|
</SectionGrid>
|
||||||
@@ -216,8 +284,26 @@ export function CompareAcademics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const national = nationalAverages?.primary;
|
const national = nationalAverages?.primary;
|
||||||
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
|
const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct'));
|
||||||
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
||||||
|
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
|
||||||
|
// groups where single pupils move the percentage — show roughly how many
|
||||||
|
// pupils the figure rests on. Taken from the SAME yearly row that supplies
|
||||||
|
// the displayed percentage: resolving eligible_pupils and the
|
||||||
|
// disadvantaged share independently could mix years and misstate the
|
||||||
|
// cohort behind the figure.
|
||||||
|
const cohorts = urns.map((urn) => {
|
||||||
|
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||||
|
for (let i = rows.length - 1; i >= 0; i--) {
|
||||||
|
const row = rows[i];
|
||||||
|
if (row.rwm_expected_disadvantaged_pct != null) {
|
||||||
|
if (row.eligible_pupils == null || row.disadvantaged_pct == null) return null;
|
||||||
|
const cohort = Math.round((row.eligible_pupils * row.disadvantaged_pct) / 100);
|
||||||
|
return cohort > 0 ? cohort : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
@@ -233,6 +319,7 @@ export function CompareAcademics({
|
|||||||
urns={urns}
|
urns={urns}
|
||||||
schoolNames={schoolNames}
|
schoolNames={schoolNames}
|
||||||
national={national}
|
national={national}
|
||||||
|
special={specialFlags}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -246,6 +333,7 @@ export function CompareAcademics({
|
|||||||
urns={urns}
|
urns={urns}
|
||||||
schoolNames={schoolNames}
|
schoolNames={schoolNames}
|
||||||
national={national}
|
national={national}
|
||||||
|
special={specialFlags}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<p className={styles.stripNote}>
|
<p className={styles.stripNote}>
|
||||||
@@ -270,6 +358,9 @@ export function CompareAcademics({
|
|||||||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||||
{Math.round(value)}%
|
{Math.round(value)}%
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
|
{cohorts[i] != null && (
|
||||||
|
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
|
||||||
|
)}{' '}
|
||||||
{disadvantagedAnchor != null && (
|
{disadvantagedAnchor != null && (
|
||||||
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
|
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
|
||||||
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
|
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { admissionsForPhase, summariseAdmissions } from '@/lib/compareLogic';
|
||||||
import type { ComparisonData, School } from '@/lib/types';
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
import { CHART_COLORS } from '@/lib/utils';
|
import { CHART_COLORS } from '@/lib/utils';
|
||||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||||
@@ -15,11 +15,17 @@ import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from '.
|
|||||||
export function CompareAdmissions({
|
export function CompareAdmissions({
|
||||||
schools,
|
schools,
|
||||||
data,
|
data,
|
||||||
|
isSecondary = false,
|
||||||
}: {
|
}: {
|
||||||
schools: School[];
|
schools: School[];
|
||||||
data: Record<string, ComparisonData>;
|
data: Record<string, ComparisonData>;
|
||||||
|
isSecondary?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null);
|
// Admissions rounds are phase-specific: an all-through school's Year 7
|
||||||
|
// round must never stand in for Reception on the primary tab (and vice
|
||||||
|
// versa) — beside pure primaries it reads as Reception odds.
|
||||||
|
const rows = schools.map((school) => admissionsForPhase(data[String(school.urn)], isSecondary));
|
||||||
|
const roundLabel = isSecondary ? 'Year 7' : 'Reception';
|
||||||
const anyData = rows.some(Boolean);
|
const anyData = rows.some(Boolean);
|
||||||
const entryYear = rows.find(Boolean)?.year;
|
const entryYear = rows.find(Boolean)?.year;
|
||||||
const entryLabel = entryYear
|
const entryLabel = entryYear
|
||||||
@@ -28,7 +34,10 @@ export function CompareAdmissions({
|
|||||||
|
|
||||||
if (!anyData) {
|
if (!anyData) {
|
||||||
return (
|
return (
|
||||||
<Section title="Getting a place" how="No admissions data is available for these schools yet.">
|
<Section
|
||||||
|
title="Getting a place"
|
||||||
|
how={`No ${roundLabel} admissions data is available for these schools yet.`}
|
||||||
|
>
|
||||||
<></>
|
<></>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
@@ -63,7 +72,9 @@ export function CompareAdmissions({
|
|||||||
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
|
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className={s.small}>No data</span>
|
<span className={s.small}>
|
||||||
|
We don't hold {roundLabel} admissions data for this school
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
@@ -104,15 +115,28 @@ export function CompareAdmissions({
|
|||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const a = rows[i];
|
const a = rows[i];
|
||||||
const summary = summariseAdmissions(a);
|
const summary = summariseAdmissions(a);
|
||||||
|
const info = data[String(school.urn)]?.school_info;
|
||||||
|
const selective = (info?.admissions_policy ?? '').toLowerCase() === 'selective';
|
||||||
|
const faith =
|
||||||
|
!!info?.religious_denomination &&
|
||||||
|
!/^(none|does not apply|not applicable)$/i.test(info.religious_denomination);
|
||||||
let text: string | null = null;
|
let text: string | null = null;
|
||||||
if (summary.firstPrefPct != null) {
|
if (summary.firstPrefPct != null) {
|
||||||
if (summary.firstPrefPct >= 100) {
|
if (selective) {
|
||||||
|
// Selective schools: the entrance test decides, whatever the
|
||||||
|
// offer percentage looks like — never the distance template.
|
||||||
|
text =
|
||||||
|
'Entry is by entrance test — the school is selective; distance and preference rank don’t decide places.';
|
||||||
|
} else if (summary.firstPrefPct >= 100) {
|
||||||
text = `Every family who put ${school.school_name} first got a place.`;
|
text = `Every family who put ${school.school_name} first got a place.`;
|
||||||
} else if (summary.firstPrefPct >= 90) {
|
} else if (summary.firstPrefPct >= 90) {
|
||||||
text = `Nearly every family who put ${school.school_name} first got a place.`;
|
text = `Nearly every family who put ${school.school_name} first got a place.`;
|
||||||
} else if (a?.oversubscribed) {
|
} else if (a?.oversubscribed) {
|
||||||
text =
|
text = isSecondary
|
||||||
'More first-choice applications than places — check the school’s admission criteria (for most non-faith primaries, distance decides).';
|
? faith
|
||||||
|
? 'More first-choice applications than places — check the school’s admission criteria (faith-based criteria may apply).'
|
||||||
|
: 'More first-choice applications than places — check the school’s admission criteria (catchment or distance often decides, but criteria vary).'
|
||||||
|
: 'More first-choice applications than places — check the school’s admission criteria (for most non-faith primaries, distance decides).';
|
||||||
} else {
|
} else {
|
||||||
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
|
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
admissionsForPhase,
|
||||||
latestValues,
|
latestValues,
|
||||||
ofstedDisplay,
|
ofstedDisplay,
|
||||||
summariseAdmissions,
|
summariseAdmissions,
|
||||||
@@ -145,7 +146,11 @@ export function CompareAtAGlance({
|
|||||||
|
|
||||||
<Measure label="Getting a place">
|
<Measure label="Getting a place">
|
||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const summary = summariseAdmissions(data[String(school.urn)]?.admissions);
|
// Phase-matched round only — an all-through school's Year 7 round
|
||||||
|
// must not masquerade as Reception odds on the primary tab.
|
||||||
|
const summary = summariseAdmissions(
|
||||||
|
admissionsForPhase(data[String(school.urn)], isSecondary),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{summary.chip ? (
|
{summary.chip ? (
|
||||||
@@ -165,8 +170,14 @@ export function CompareAtAGlance({
|
|||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const census = data[String(school.urn)]?.census;
|
const census = data[String(school.urn)]?.census;
|
||||||
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
|
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
|
||||||
|
// An all-through school's roll covers every age group, so judging
|
||||||
|
// it against the single-phase median ("Much larger than average")
|
||||||
|
// is meaningless — label the roll honestly instead.
|
||||||
|
const isAllThrough = /all.?through/i.test(school.phase ?? '');
|
||||||
let sizeNote: string | null = null;
|
let sizeNote: string | null = null;
|
||||||
if (pupils != null && medianPupils != null) {
|
if (isAllThrough) {
|
||||||
|
sizeNote = 'Whole-school roll (all-through, all ages)';
|
||||||
|
} else if (pupils != null && medianPupils != null) {
|
||||||
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
|
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
|
||||||
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
|
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
|
||||||
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
|
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export function CompareCommunity({
|
|||||||
const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary;
|
const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary;
|
||||||
|
|
||||||
const fsmChip = (value: number | null) => {
|
const fsmChip = (value: number | null) => {
|
||||||
const anchor = bench?.fsm_pct ?? bench?.disadvantaged_pct ?? null;
|
// FSM is anchored only against a real FSM benchmark (census-sourced,
|
||||||
|
// pupil-weighted). disadvantaged_pct is a different measure (FSM6+CLA)
|
||||||
|
// — never fall back across definitions; no anchor means no chip.
|
||||||
|
const anchor = bench?.fsm_pct ?? null;
|
||||||
if (value == null || anchor == null) return null;
|
if (value == null || anchor == null) return null;
|
||||||
const v = verdict(value, anchor, 3);
|
const v = verdict(value, anchor, 3);
|
||||||
return (
|
return (
|
||||||
@@ -45,10 +48,25 @@ export function CompareCommunity({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const anyAllThrough = schools.some((school) => /all.?through/i.test(school.phase ?? ''));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
title="Who goes there"
|
title="Who goes there"
|
||||||
how="The school's community, from the latest school census. State-school averages are computed from our dataset and shown for context — there's no “right” number here."
|
how={
|
||||||
|
<>
|
||||||
|
The school's community, from the latest school census. State-school averages are
|
||||||
|
computed from our dataset and shown for context — there's no “right”
|
||||||
|
number here.
|
||||||
|
{anyAllThrough && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
For all-through schools these figures cover the whole school, all ages — not just
|
||||||
|
the {isSecondary ? 'secondary' : 'primary'} phase.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SectionGrid schools={schools}>
|
<SectionGrid schools={schools}>
|
||||||
<Measure label="Pupils on roll">
|
<Measure label="Pupils on roll">
|
||||||
|
|||||||
@@ -108,14 +108,21 @@ function JudgementDetailCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyAreas: Array<[string, number | null]> = [
|
const legacyAreas: Array<[string, number | null | undefined]> = [
|
||||||
['Quality of education', ofsted.quality_of_education],
|
['Quality of education', ofsted.quality_of_education],
|
||||||
['Behaviour & attitudes', ofsted.behaviour_attitudes],
|
['Behaviour & attitudes', ofsted.behaviour_attitudes],
|
||||||
['Personal development', ofsted.personal_development],
|
['Personal development', ofsted.personal_development],
|
||||||
['Leadership & management', ofsted.leadership_management],
|
['Leadership & management', ofsted.leadership_management],
|
||||||
['Early years provision', ofsted.early_years_provision],
|
['Early years provision', ofsted.early_years_provision],
|
||||||
|
['Sixth form provision', ofsted.sixth_form_provision],
|
||||||
];
|
];
|
||||||
const published = legacyAreas.filter(([, grade]) => grade != null);
|
// Only real Ofsted grades (1–4) are judgements. The MI file uses sentinel
|
||||||
|
// codes for "not applicable / no judgement" (9, and 0/8 variants) — those
|
||||||
|
// must never render as a rating chip.
|
||||||
|
const published = legacyAreas.filter(
|
||||||
|
(entry): entry is [string, number] =>
|
||||||
|
entry[1] != null && entry[1] >= 1 && entry[1] <= 4,
|
||||||
|
);
|
||||||
|
|
||||||
if (published.length === 0) {
|
if (published.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -180,10 +187,18 @@ export function CompareOfsted({
|
|||||||
<Measure label="Inspected">
|
<Measure label="Inspected">
|
||||||
{schools.map((school, i) => {
|
{schools.map((school, i) => {
|
||||||
const ofsted = data[String(school.urn)]?.ofsted;
|
const ofsted = data[String(school.urn)]?.ofsted;
|
||||||
const age = yearsSince(ofsted?.inspection_date ?? null);
|
// A report card is dated by its OWN inspection date. The legacy
|
||||||
|
// inspection_date belongs to an older inspection and must never
|
||||||
|
// be shown against a report card (report cards exist only from
|
||||||
|
// Nov 2025).
|
||||||
|
const dateIso =
|
||||||
|
displays[i].kind === 'report_card'
|
||||||
|
? ofsted?.rc_inspection_date ?? null
|
||||||
|
: ofsted?.inspection_date ?? null;
|
||||||
|
const age = yearsSince(dateIso);
|
||||||
return (
|
return (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
{formatInspectionDate(ofsted?.inspection_date ?? null)}{' '}
|
{formatInspectionDate(dateIso)}{' '}
|
||||||
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
|
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
@@ -224,8 +239,17 @@ export function CompareOfsted({
|
|||||||
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
||||||
return (
|
return (
|
||||||
<Cell key={school.urn} school={school} index={i}>
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer">
|
{/* Short visible label to save space on mobile (the coloured
|
||||||
{school.school_name}'s Ofsted page →
|
school name already leads the row); the full name stays in
|
||||||
|
aria-label so screen readers can tell the links apart. */}
|
||||||
|
<a
|
||||||
|
className={s.link}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`${school.school_name}'s Ofsted page`}
|
||||||
|
>
|
||||||
|
Ofsted page →
|
||||||
</a>
|
</a>
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,18 +60,10 @@
|
|||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ComparisonChart runs Chart.js with maintainAspectRatio:false, so it fills
|
/* ComparisonChart owns its own canvas height now (a definite px value per
|
||||||
its container's height — which must be *definite*. A min-height alone does
|
breakpoint), with the mobile chip legend above and the gap note below it
|
||||||
not resolve the chart wrapper's height:100%, leaving Chart.js to fall back
|
flowing at natural size. This box therefore only needs to not constrain
|
||||||
to its ~150px default (a squashed sliver). Give it a real height. */
|
that height — no fixed height, or the note would again eat the plot. */
|
||||||
.chartBox {
|
.chartBox {
|
||||||
height: 420px;
|
min-height: 0;
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
/* Taller on mobile: the mobile-only school chips sit above the canvas and
|
|
||||||
wrap to two rows for 3+ schools, so the plot keeps a usable height. */
|
|
||||||
.chartBox {
|
|
||||||
height: 360px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function TrendsExplorer({
|
|||||||
metric={metric}
|
metric={metric}
|
||||||
metricLabel={metricLabel}
|
metricLabel={metricLabel}
|
||||||
nationalByYear={nationalByYear}
|
nationalByYear={nationalByYear}
|
||||||
|
isSecondary={!isPrimaryPhase}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,15 +72,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
font-size: 0.8rem;
|
/* Slightly larger than the values below it so the school each row belongs
|
||||||
|
to is easy to read on mobile (hidden on desktop, where the column header
|
||||||
|
names the school). */
|
||||||
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--sc, var(--text-secondary));
|
color: var(--sc, var(--text-secondary));
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cellDot {
|
.cellDot {
|
||||||
width: 9px;
|
width: 10px;
|
||||||
height: 9px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--dot, var(--text-muted));
|
background: var(--dot, var(--text-muted));
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export interface CompareChart {
|
|||||||
/** True when England published a 2021/22 figure but no school has one —
|
/** True when England published a 2021/22 figure but no school has one —
|
||||||
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */
|
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */
|
||||||
showUnpublished202122Note: boolean;
|
showUnpublished202122Note: boolean;
|
||||||
|
/** Years where the England overlay has a value but no school does — the
|
||||||
|
* chart shows a dashed-line-only stretch that needs explaining (KS2 and
|
||||||
|
* KS4 have different honest explanations, so the component owns the copy). */
|
||||||
|
englandOnlyYears: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCompareChart(
|
export function buildCompareChart(
|
||||||
@@ -92,11 +96,12 @@ export function buildCompareChart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const idx202122 = years.indexOf(202122);
|
const englandOnlyYears = years.filter(
|
||||||
const showUnpublished202122Note =
|
(year, i) =>
|
||||||
idx202122 >= 0 &&
|
englandDataset?.data[i] != null && schoolDatasets.every((ds) => ds.data[i] == null),
|
||||||
englandDataset?.data[idx202122] != null &&
|
);
|
||||||
schoolDatasets.every((ds) => ds.data[idx202122] == null);
|
|
||||||
|
|
||||||
return { years, schoolDatasets, englandDataset, showUnpublished202122Note };
|
const showUnpublished202122Note = englandOnlyYears.includes(202122);
|
||||||
|
|
||||||
|
return { years, schoolDatasets, englandDataset, showUnpublished202122Note, englandOnlyYears };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,41 @@ export interface AdmissionsSummary {
|
|||||||
interest: string | null;
|
interest: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the admissions round for the ACTIVE phase tab. An all-through school
|
||||||
|
* can carry only a Year 7 (Secondary) round — rendering that beside pure
|
||||||
|
* primaries' Reception rounds made 433-forms-for-173-places read as
|
||||||
|
* Reception odds. Rows matching the target phase win (latest year first);
|
||||||
|
* rows tagged with the OTHER phase are never substituted. Untagged rows
|
||||||
|
* (legacy data, no school_phase) are used only when no row carries a phase.
|
||||||
|
*/
|
||||||
|
export function admissionsForPhase(
|
||||||
|
data:
|
||||||
|
| { admissions?: SchoolAdmissions | null; admissions_history?: SchoolAdmissions[] }
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
isSecondary: boolean,
|
||||||
|
): SchoolAdmissions | null {
|
||||||
|
if (!data) return null;
|
||||||
|
const rows: SchoolAdmissions[] = [
|
||||||
|
...(data.admissions_history ?? []),
|
||||||
|
...(data.admissions ? [data.admissions] : []),
|
||||||
|
];
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const target = isSecondary ? 'secondary' : 'primary';
|
||||||
|
const byYearDesc = (a: SchoolAdmissions, b: SchoolAdmissions) => (b.year ?? 0) - (a.year ?? 0);
|
||||||
|
|
||||||
|
const matching = rows
|
||||||
|
.filter((r) => r.school_phase?.toLowerCase() === target)
|
||||||
|
.sort(byYearDesc);
|
||||||
|
if (matching.length > 0) return matching[0];
|
||||||
|
|
||||||
|
const tagged = rows.some((r) => r.school_phase != null);
|
||||||
|
if (!tagged) return [...rows].sort(byYearDesc)[0];
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function summariseAdmissions(
|
export function summariseAdmissions(
|
||||||
a: SchoolAdmissions | null | undefined,
|
a: SchoolAdmissions | null | undefined,
|
||||||
): AdmissionsSummary {
|
): AdmissionsSummary {
|
||||||
@@ -157,6 +192,12 @@ export function summariseAdmissions(
|
|||||||
if (pct != null) {
|
if (pct != null) {
|
||||||
if (pct >= 100) {
|
if (pct >= 100) {
|
||||||
chip = { tone: 'good', text: 'All first choices offered' };
|
chip = { tone: 'good', text: 'All first choices offered' };
|
||||||
|
} else if (pct < 50) {
|
||||||
|
// Banded, not one blanket chip: "Over 1 in 4" on a school where more
|
||||||
|
// than half missed out understated the worst cases by half.
|
||||||
|
chip = { tone: 'warn', text: 'More than half of first choices missed out' };
|
||||||
|
} else if (pct < 67) {
|
||||||
|
chip = { tone: 'warn', text: 'About 1 in 3 first choices missed out' };
|
||||||
} else if (pct < 75) {
|
} else if (pct < 75) {
|
||||||
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
|
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export interface School {
|
|||||||
|
|
||||||
// Ofsted (for list view — summary only)
|
// Ofsted (for list view — summary only)
|
||||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
ofsted_grade?: 1 | 2 | 3 | 4 | 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;
|
||||||
ofsted_date?: string | null;
|
ofsted_date?: string | null;
|
||||||
ofsted_framework?: string | null;
|
ofsted_framework?: string | null;
|
||||||
}
|
}
|
||||||
@@ -79,12 +82,16 @@ export interface School {
|
|||||||
export interface OfstedInspection {
|
export interface OfstedInspection {
|
||||||
framework: 'OEIF' | 'ReportCard' | null;
|
framework: 'OEIF' | 'ReportCard' | null;
|
||||||
inspection_date: string | null;
|
inspection_date: string | null;
|
||||||
|
/** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
|
||||||
|
rc_inspection_date?: string | null;
|
||||||
inspection_type: string | null;
|
inspection_type: string | null;
|
||||||
// OEIF fields (old framework, pre-Nov 2025)
|
// OEIF fields (old framework, pre-Nov 2025)
|
||||||
overall_effectiveness: 1 | 2 | 3 | 4 | null;
|
overall_effectiveness: 1 | 2 | 3 | 4 | null;
|
||||||
quality_of_education: number | null;
|
quality_of_education: number | null;
|
||||||
behaviour_attitudes: number | null;
|
behaviour_attitudes: number | null;
|
||||||
personal_development: number | null;
|
personal_development: number | null;
|
||||||
|
/** Sixth-form judgement where applicable; sentinel 9 = not applicable. */
|
||||||
|
sixth_form_provision?: number | null;
|
||||||
leadership_management: number | null;
|
leadership_management: number | null;
|
||||||
early_years_provision: number | null;
|
early_years_provision: number | null;
|
||||||
previous_overall: number | null;
|
previous_overall: number | null;
|
||||||
|
|||||||
+71
-6
@@ -680,6 +680,42 @@ export function buildSchoolSummary(
|
|||||||
return parts.join(', ') + '.';
|
return parts.join(', ') + '.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Legacy (OEIF) sub-judgement areas ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface OfstedLegacyArea {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The published OEIF sub-judgement areas for the legacy Ofsted layout, in
|
||||||
|
* display order. Only real grades (1–4) are returned: Ofsted's sentinel
|
||||||
|
* codes for "not applicable / no judgement" (9, and any 0/8 variants) and
|
||||||
|
* nulls are filtered out, so a cryptic "9" never renders as a rating.
|
||||||
|
* Sixth Form provision is included where a school has one — it was
|
||||||
|
* previously dropped from the detail grid entirely.
|
||||||
|
*/
|
||||||
|
export function ofstedLegacyAreas(ofsted: {
|
||||||
|
quality_of_education?: number | null;
|
||||||
|
behaviour_attitudes?: number | null;
|
||||||
|
personal_development?: number | null;
|
||||||
|
leadership_management?: number | null;
|
||||||
|
early_years_provision?: number | null;
|
||||||
|
sixth_form_provision?: number | null;
|
||||||
|
}): OfstedLegacyArea[] {
|
||||||
|
const candidates: Array<[string, number | null | undefined]> = [
|
||||||
|
['Quality of Teaching', ofsted.quality_of_education],
|
||||||
|
['Behaviour in School', ofsted.behaviour_attitudes],
|
||||||
|
["Pupils' Wider Development", ofsted.personal_development],
|
||||||
|
['School Leadership', ofsted.leadership_management],
|
||||||
|
['Early Years (Reception)', ofsted.early_years_provision],
|
||||||
|
['Sixth Form', ofsted.sixth_form_provision],
|
||||||
|
];
|
||||||
|
return candidates
|
||||||
|
.filter((c): c is [string, number] => c[1] != null && c[1] >= 1 && c[1] <= 4)
|
||||||
|
.map(([label, value]) => ({ label, value }));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── List-level Ofsted badge ──────────────────────────────────────────────────
|
// ─── List-level Ofsted badge ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface OfstedListBadge {
|
export interface OfstedListBadge {
|
||||||
@@ -691,9 +727,12 @@ export interface OfstedListBadge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Ofsted badge for a school card in the list/map view.
|
* Build the Ofsted badge for a school card in the list/map view.
|
||||||
* Three states:
|
* States, in priority order:
|
||||||
|
* - Report Card school (ofsted_rc_date set): "Report Card · YYYY" in purple.
|
||||||
|
* 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
|
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
|
||||||
* - ReportCard school (ofsted_framework === 'ReportCard'): "Report Card · YYYY" in purple
|
|
||||||
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
|
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
|
||||||
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the
|
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the
|
||||||
* detail page's hero chip so a school never reads as both inspected and
|
* detail page's hero chip so a school never reads as both inspected and
|
||||||
@@ -704,7 +743,16 @@ export function buildOfstedListBadge(school: {
|
|||||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||||
ofsted_date?: string | null;
|
ofsted_date?: string | null;
|
||||||
ofsted_framework?: string | null;
|
ofsted_framework?: string | null;
|
||||||
|
ofsted_rc_date?: string | null;
|
||||||
}): OfstedListBadge {
|
}): OfstedListBadge {
|
||||||
|
// 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.
|
||||||
|
if (school.ofsted_rc_date) {
|
||||||
|
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||||||
|
return { label: `Report Card · ${rcYear}`, cssClass: 'ofstedRc' };
|
||||||
|
}
|
||||||
|
|
||||||
const year = school.ofsted_date
|
const year = school.ofsted_date
|
||||||
? new Date(school.ofsted_date).getFullYear()
|
? new Date(school.ofsted_date).getFullYear()
|
||||||
: null;
|
: null;
|
||||||
@@ -723,10 +771,6 @@ export function buildOfstedListBadge(school: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (school.ofsted_framework === 'ReportCard') {
|
|
||||||
return { label: `Report Card${yearStr}`, cssClass: 'ofstedRc' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// An inspection is on record (date or framework present) but carries no
|
// An inspection is on record (date or framework present) but carries no
|
||||||
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
|
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
|
||||||
// that has genuinely never been inspected.
|
// that has genuinely never been inspected.
|
||||||
@@ -751,3 +795,24 @@ export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
|
|||||||
export function isProposedToClose(school: { status?: string | null }): boolean {
|
export function isProposedToClose(school: { status?: string | null }): boolean {
|
||||||
return school.status === PROPOSED_TO_CLOSE_STATUS;
|
return school.status === PROPOSED_TO_CLOSE_STATUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special schools, pupil referral units and alternative provision teach pupils
|
||||||
|
* with SEND or outside mainstream settings. Their pupils sit the same KS2/KS4
|
||||||
|
* assessments but very few reach the mainstream "expected standard", so the
|
||||||
|
* headline attainment measures — and any comparison to the England average —
|
||||||
|
* are not a fair judgement of the school. Callers use this to drop the
|
||||||
|
* mainstream-benchmark framing (deltas, "below England", national markers)
|
||||||
|
* rather than portray these schools as failing.
|
||||||
|
*
|
||||||
|
* Detection is by establishment type: every DfE special-school type contains
|
||||||
|
* "special" (e.g. "Community special school", "Academy special converter/sponsor
|
||||||
|
* led", "Foundation special school", "Non-maintained special school", "Free
|
||||||
|
* schools special", "Other independent special school"); PRUs and alternative
|
||||||
|
* provision are matched by name. Special schools carry a mainstream `phase`
|
||||||
|
* (Primary/Secondary/All-through), so `phase` alone can't identify them.
|
||||||
|
*/
|
||||||
|
export function isSpecialSchool(school: { school_type?: string | null }): boolean {
|
||||||
|
const t = (school.school_type ?? '').toLowerCase();
|
||||||
|
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ with DAG(
|
|||||||
|
|
||||||
dbt_build_ees = BashOperator(
|
dbt_build_ees = BashOperator(
|
||||||
task_id="dbt_build",
|
task_id="dbt_build",
|
||||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+",
|
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+",
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_typesense_ees = BashOperator(
|
sync_typesense_ees = BashOperator(
|
||||||
|
|||||||
@@ -564,6 +564,74 @@ class EESKs2NationalStream(Stream):
|
|||||||
yield record
|
yield record
|
||||||
|
|
||||||
|
|
||||||
|
# ── KS4 National Headlines (national level only — one row per year) ──────────
|
||||||
|
# Dataset: "National characteristics summary data" (Key stage 4 performance).
|
||||||
|
# Official England state-funded headline measures, 2018/19 → latest.
|
||||||
|
# Suppressed values ('z', 'x') → NULL downstream. Progress 8 is legitimately
|
||||||
|
# absent in years with no KS2 baseline (e.g. 2024/25) — that is DfE policy,
|
||||||
|
# not missing data.
|
||||||
|
|
||||||
|
_KS4_NATIONAL_CSV_URL = (
|
||||||
|
"https://explore-education-statistics.service.gov.uk/data-catalogue/"
|
||||||
|
"data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
_KS4_NATIONAL_COL_MAP = {
|
||||||
|
"attainment8_average": "attainment_8_score",
|
||||||
|
"progress8_average": "progress_8_score",
|
||||||
|
"engmath_94_percent": "english_maths_standard_pass_pct",
|
||||||
|
"engmath_95_percent": "english_maths_strong_pass_pct",
|
||||||
|
"ebacc_entering_percent": "ebacc_entry_pct",
|
||||||
|
"ebacc_94_percent": "ebacc_standard_pass_pct",
|
||||||
|
"ebacc_95_percent": "ebacc_strong_pass_pct",
|
||||||
|
"ebacc_aps_average": "ebacc_avg_score",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EESKs4NationalStream(Stream):
|
||||||
|
"""National KS4 headline averages — one row per academic year.
|
||||||
|
|
||||||
|
Filters to geographic_level == 'National', establishment_type_group ==
|
||||||
|
'All state-funded', breakdown_topic == 'Total', breakdown == 'Total'
|
||||||
|
so only the England-wide all-pupils row per year is emitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "ees_ks4_national"
|
||||||
|
primary_keys = ["time_period"]
|
||||||
|
replication_key = None
|
||||||
|
|
||||||
|
schema = th.PropertiesList(
|
||||||
|
th.Property("time_period", th.StringType, required=True),
|
||||||
|
*[th.Property(out, th.StringType) for out in _KS4_NATIONAL_COL_MAP.values()],
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
def get_records(self, context):
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
self.logger.info("Downloading KS4 national headlines: %s", _KS4_NATIONAL_CSV_URL)
|
||||||
|
resp = requests.get(_KS4_NATIONAL_CSV_URL, timeout=60)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
|
||||||
|
df.columns = [c.strip().lower() for c in df.columns]
|
||||||
|
|
||||||
|
for col, want in [
|
||||||
|
("geographic_level", "national"),
|
||||||
|
("establishment_type_group", "all state-funded"),
|
||||||
|
("breakdown_topic", "total"),
|
||||||
|
("breakdown", "total"),
|
||||||
|
]:
|
||||||
|
if col in df.columns:
|
||||||
|
df = df[df[col].str.strip().str.lower() == want]
|
||||||
|
|
||||||
|
self.logger.info("Emitting %d national KS4 rows", len(df))
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
record = {"time_period": row.get("time_period", "").strip()}
|
||||||
|
for csv_col, field in _KS4_NATIONAL_COL_MAP.items():
|
||||||
|
record[field] = row.get(csv_col, "").strip()
|
||||||
|
yield record
|
||||||
|
|
||||||
|
|
||||||
# ── Legacy KS2 (pre-COVID wide format from DfE performance tables) ────────────
|
# ── Legacy KS2 (pre-COVID wide format from DfE performance tables) ────────────
|
||||||
# The DfE "Compare School Performance" site published school-level KS2 CSVs
|
# The DfE "Compare School Performance" site published school-level KS2 CSVs
|
||||||
# in a wide format (one row per school, ~300 columns). EES only has school-level
|
# in a wide format (one row per school, ~300 columns). EES only has school-level
|
||||||
@@ -903,6 +971,7 @@ class TapUKEES(Tap):
|
|||||||
LegacyKS2Stream(self),
|
LegacyKS2Stream(self),
|
||||||
LegacyKS4Stream(self),
|
LegacyKS4Stream(self),
|
||||||
EESKs2NationalStream(self),
|
EESKs2NationalStream(self),
|
||||||
|
EESKs4NationalStream(self),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ COLUMN_PRIORITY = {
|
|||||||
"rc_leadership_governance": ["Leadership and governance"],
|
"rc_leadership_governance": ["Leadership and governance"],
|
||||||
"rc_early_years": ["Early years (where applicable)"],
|
"rc_early_years": ["Early years (where applicable)"],
|
||||||
"rc_sixth_form": ["Post-16 provision (where applicable)"],
|
"rc_sixth_form": ["Post-16 provision (where applicable)"],
|
||||||
|
# Date of the latest FULL inspection — in the renewed framework this is
|
||||||
|
# the report-card inspection's own start date (col "Inspection start
|
||||||
|
# date"), distinct from the legacy OEIF graded/ungraded dates above.
|
||||||
|
"rc_inspection_date": ["Inspection start date"],
|
||||||
"report_url": [
|
"report_url": [
|
||||||
"Web Link (opens in new window)",
|
"Web Link (opens in new window)",
|
||||||
"Web link to Ofsted provider page",
|
"Web link to Ofsted provider page",
|
||||||
@@ -98,16 +102,44 @@ COLUMN_PRIORITY = {
|
|||||||
|
|
||||||
|
|
||||||
def discover_csv_url() -> str | None:
|
def discover_csv_url() -> str | None:
|
||||||
"""Scrape GOV.UK page to find the latest MI CSV download link."""
|
"""Scrape GOV.UK page to find the latest MI CSV download link.
|
||||||
|
|
||||||
|
The page lists a decade of monthly files, oldest first — take the
|
||||||
|
newest 'latest inspections as at <date>' link by parsing its date,
|
||||||
|
never matches[0] (that is a 2017 file).
|
||||||
|
"""
|
||||||
resp = requests.get(GOV_UK_PAGE, timeout=30)
|
resp = requests.get(GOV_UK_PAGE, timeout=30)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
# Look for CSV attachment links
|
csv_links = re.findall(
|
||||||
matches = re.findall(
|
|
||||||
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
|
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
|
||||||
resp.text,
|
resp.text,
|
||||||
)
|
)
|
||||||
if matches:
|
|
||||||
return matches[0]
|
months = {
|
||||||
|
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
|
||||||
|
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12,
|
||||||
|
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'jun': 6,
|
||||||
|
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
|
||||||
|
}
|
||||||
|
parsed_links = []
|
||||||
|
for link in csv_links:
|
||||||
|
normalized = link.lower().replace('-', '_')
|
||||||
|
if 'latest_inspections_as_at' not in normalized:
|
||||||
|
continue
|
||||||
|
match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized)
|
||||||
|
if match:
|
||||||
|
day, month_str, year = match.groups()
|
||||||
|
month = months.get(month_str)
|
||||||
|
if month:
|
||||||
|
try:
|
||||||
|
parsed_links.append((datetime(int(year), month, int(day)), link))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
parsed_links.sort(reverse=True)
|
||||||
|
if parsed_links:
|
||||||
|
return parsed_links[0][1]
|
||||||
|
if csv_links:
|
||||||
|
return csv_links[-1]
|
||||||
# Fall back to ODS
|
# Fall back to ODS
|
||||||
matches = re.findall(
|
matches = re.findall(
|
||||||
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
|
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
|
||||||
@@ -191,6 +223,7 @@ class OfstedInspectionsStream(Stream):
|
|||||||
th.Property("rc_leadership_governance", th.StringType),
|
th.Property("rc_leadership_governance", th.StringType),
|
||||||
th.Property("rc_early_years", th.StringType),
|
th.Property("rc_early_years", th.StringType),
|
||||||
th.Property("rc_sixth_form", th.StringType),
|
th.Property("rc_sixth_form", th.StringType),
|
||||||
|
th.Property("rc_inspection_date", th.StringType),
|
||||||
th.Property("report_url", th.StringType),
|
th.Property("report_url", th.StringType),
|
||||||
).to_dict()
|
).to_dict()
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ select
|
|||||||
rc_leadership_governance,
|
rc_leadership_governance,
|
||||||
rc_early_years,
|
rc_early_years,
|
||||||
rc_sixth_form,
|
rc_sixth_form,
|
||||||
|
rc_inspection_date,
|
||||||
report_url
|
report_url
|
||||||
from ranked
|
from ranked
|
||||||
where rn = 1
|
where rn = 1
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ models:
|
|||||||
- name: year
|
- name: year
|
||||||
tests: [not_null]
|
tests: [not_null]
|
||||||
|
|
||||||
|
- name: fact_census_benchmarks
|
||||||
|
description: >
|
||||||
|
State-school context benchmarks from the pupil census — one row per
|
||||||
|
phase (primary/secondary), latest census year. fsm_pct/eal_pct are
|
||||||
|
pupil-weighted means; consumers label them "state-school average
|
||||||
|
(computed from our dataset)", never "England average".
|
||||||
|
columns:
|
||||||
|
- name: phase
|
||||||
|
tests: [not_null, unique]
|
||||||
|
|
||||||
- name: fact_admissions
|
- name: fact_admissions
|
||||||
description: School admissions — one row per URN per year
|
description: School admissions — one row per URN per year
|
||||||
columns:
|
columns:
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Mart: state-school context benchmarks from the pupil census — one row per
|
||||||
|
-- phase, latest census year. Computed at import time (never per request).
|
||||||
|
-- fsm_pct / eal_pct are pupil-weighted means, i.e. "what % of pupils", not
|
||||||
|
-- "the median school" — this matches how DfE quotes national FSM/EAL rates.
|
||||||
|
-- Consumers must label these "state-school average (computed from our
|
||||||
|
-- dataset)" (spec §8.6), never "England average".
|
||||||
|
|
||||||
|
with latest as (
|
||||||
|
select max(year) as year from {{ ref('fact_pupil_characteristics') }}
|
||||||
|
),
|
||||||
|
|
||||||
|
classified as (
|
||||||
|
select
|
||||||
|
case
|
||||||
|
when p.phase_type_grouping ilike '%primary%' then 'primary'
|
||||||
|
when p.phase_type_grouping ilike '%secondary%' then 'secondary'
|
||||||
|
end as phase,
|
||||||
|
p.total_pupils,
|
||||||
|
p.fsm_pct,
|
||||||
|
p.eal_pct,
|
||||||
|
l.year
|
||||||
|
from {{ ref('fact_pupil_characteristics') }} p
|
||||||
|
join latest l on p.year = l.year
|
||||||
|
where p.total_pupils is not null and p.total_pupils > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
select
|
||||||
|
phase,
|
||||||
|
max(year) as year,
|
||||||
|
round((sum(fsm_pct * total_pupils) filter (where fsm_pct is not null)
|
||||||
|
/ nullif(sum(total_pupils) filter (where fsm_pct is not null), 0))::numeric, 1) as fsm_pct,
|
||||||
|
round((sum(eal_pct * total_pupils) filter (where eal_pct is not null)
|
||||||
|
/ nullif(sum(total_pupils) filter (where eal_pct is not null), 0))::numeric, 1) as eal_pct,
|
||||||
|
round(percentile_cont(0.5) within group (order by total_pupils))::integer as median_pupils
|
||||||
|
from classified
|
||||||
|
where phase is not null
|
||||||
|
group by phase
|
||||||
@@ -1,25 +1,22 @@
|
|||||||
{{ config(materialized='table') }}
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
-- Mart: Computed national KS4 averages — one row per academic year.
|
-- Mart: OFFICIAL DfE KS4 national headline averages — one row per academic
|
||||||
-- Unlike fact_ks2_national_averages (official DfE figures), DfE publishes no
|
-- year (England, state-funded, all pupils), from the EES national dataset.
|
||||||
-- KS4 national-headline dataset we ingest yet, so these are means computed
|
-- Replaces the previous unweighted school-level means, which were 7–15
|
||||||
-- across the state schools in our dataset. Computed once at build time so the
|
-- points off every headline measure and produced an arithmetically
|
||||||
-- API never has to aggregate the full performance table per request.
|
-- impossible national Progress 8. gcse_grade_91_pct has no official
|
||||||
-- Semantics match the API's previous per-request computation: rows where
|
-- national series and is NULL (schema kept for the API model).
|
||||||
-- attainment_8_score is non-null; per-column means ignore NULLs.
|
|
||||||
|
|
||||||
select
|
select
|
||||||
year,
|
year,
|
||||||
round(avg(attainment_8_score)::numeric, 2) as attainment_8_score,
|
attainment_8_score,
|
||||||
round(avg(progress_8_score)::numeric, 2) as progress_8_score,
|
progress_8_score,
|
||||||
round(avg(english_maths_standard_pass_pct)::numeric, 2) as english_maths_standard_pass_pct,
|
english_maths_standard_pass_pct,
|
||||||
round(avg(english_maths_strong_pass_pct)::numeric, 2) as english_maths_strong_pass_pct,
|
english_maths_strong_pass_pct,
|
||||||
round(avg(ebacc_entry_pct)::numeric, 2) as ebacc_entry_pct,
|
ebacc_entry_pct,
|
||||||
round(avg(ebacc_standard_pass_pct)::numeric, 2) as ebacc_standard_pass_pct,
|
ebacc_standard_pass_pct,
|
||||||
round(avg(ebacc_strong_pass_pct)::numeric, 2) as ebacc_strong_pass_pct,
|
ebacc_strong_pass_pct,
|
||||||
round(avg(ebacc_avg_score)::numeric, 2) as ebacc_avg_score,
|
ebacc_avg_score,
|
||||||
round(avg(gcse_grade_91_pct)::numeric, 2) as gcse_grade_91_pct
|
cast(null as double precision) as gcse_grade_91_pct
|
||||||
from {{ ref('fact_ks4_performance') }}
|
from {{ ref('stg_ees_ks4_national') }}
|
||||||
where attainment_8_score is not null
|
|
||||||
group by year
|
|
||||||
order by year
|
order by year
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ select
|
|||||||
rc_leadership_governance,
|
rc_leadership_governance,
|
||||||
rc_early_years,
|
rc_early_years,
|
||||||
rc_sixth_form,
|
rc_sixth_form,
|
||||||
|
rc_inspection_date,
|
||||||
report_url
|
report_url
|
||||||
from {{ ref('stg_ofsted_inspections') }}
|
from {{ ref('stg_ofsted_inspections') }}
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ sources:
|
|||||||
- name: ees_ks2_national
|
- name: ees_ks2_national
|
||||||
description: KS2 national headline averages from DfE EES data catalogue — one row per academic year
|
description: KS2 national headline averages from DfE EES data catalogue — one row per academic year
|
||||||
|
|
||||||
|
- name: ees_ks4_national
|
||||||
|
description: Official KS4 national headline averages from DfE EES data catalogue — one row per academic year
|
||||||
|
|
||||||
# Phonics: no school-level data on EES (only national/LA level)
|
# Phonics: no school-level data on EES (only national/LA level)
|
||||||
|
|
||||||
- name: fbit_finance
|
- name: fbit_finance
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Staging model: official DfE KS4 national headline averages — one row per
|
||||||
|
-- academic year (England, all state-funded, all pupils). Source: EES data
|
||||||
|
-- catalogue "National characteristics summary data". Suppressed values
|
||||||
|
-- ('z', 'x') are coerced to NULL by safe_numeric — Progress 8 is 'z' in
|
||||||
|
-- years with no KS2 baseline (e.g. 2024/25): legitimately unpublished.
|
||||||
|
|
||||||
|
select
|
||||||
|
cast(trim(time_period) as integer) as year,
|
||||||
|
{{ safe_numeric('attainment_8_score') }} as attainment_8_score,
|
||||||
|
{{ safe_numeric('progress_8_score') }} as progress_8_score,
|
||||||
|
{{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
|
||||||
|
{{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
|
||||||
|
{{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_avg_score') }} as ebacc_avg_score
|
||||||
|
from {{ source('raw', 'ees_ks4_national') }}
|
||||||
|
where time_period ~ '^[0-9]+$'
|
||||||
@@ -49,6 +49,12 @@ renamed as (
|
|||||||
{{ parse_report_card_grade('rc_early_years') }}::integer as rc_early_years,
|
{{ parse_report_card_grade('rc_early_years') }}::integer as rc_early_years,
|
||||||
{{ parse_report_card_grade('rc_sixth_form') }}::integer as rc_sixth_form,
|
{{ parse_report_card_grade('rc_sixth_form') }}::integer as rc_sixth_form,
|
||||||
|
|
||||||
|
-- Start date of the latest FULL inspection (the report-card
|
||||||
|
-- inspection in the renewed framework). Guarded in the final select:
|
||||||
|
-- only kept when the row actually carries report-card grades, because
|
||||||
|
-- in legacy-format files this column is the legacy inspection date.
|
||||||
|
to_date(nullif(trim(rc_inspection_date), 'NULL'), 'DD/MM/YYYY') as rc_inspection_date_raw,
|
||||||
|
|
||||||
nullif(trim(report_url), 'NULL') as report_url
|
nullif(trim(report_url), 'NULL') as report_url
|
||||||
from source
|
from source
|
||||||
where urn is not null
|
where urn is not null
|
||||||
@@ -58,5 +64,17 @@ renamed as (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
select * from renamed
|
select
|
||||||
|
*,
|
||||||
|
case
|
||||||
|
when rc_safeguarding_met is not null
|
||||||
|
or rc_inclusion is not null
|
||||||
|
or rc_curriculum_teaching is not null
|
||||||
|
or rc_achievement is not null
|
||||||
|
or rc_attendance_behaviour is not null
|
||||||
|
or rc_personal_development is not null
|
||||||
|
or rc_leadership_governance is not null
|
||||||
|
then rc_inspection_date_raw
|
||||||
|
end as rc_inspection_date
|
||||||
|
from renamed
|
||||||
where inspection_date is not null
|
where inspection_date is not null
|
||||||
|
|||||||
Reference in New Issue
Block a user