Merge branch 'main' into fix/gias-blank-name-codes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 33s

This commit is contained in:
2026-07-16 09:14:42 +00:00
74 changed files with 8558 additions and 1071 deletions
@@ -0,0 +1,591 @@
# Compare-Screen Data Foundation (Pipeline PR) 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:** Land every pipeline/dbt change the compare-screen redesign needs (spec §5 + §8 of `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md`): promote raw-but-unstored fields to marts, close the national-averages gaps, and wire the Ofsted report-card columns.
**Architecture:** Meltano Singer taps load `raw.*` tables; dbt builds `staging``marts` (read-only for the backend). All changes here are additive columns/rows — no breaking changes to existing marts. The full `dbt build` runs on the server via the Airflow DAGs; locally we gate with `dbt parse` (no DB needed) plus network-only diagnostic scripts.
**Tech Stack:** Python (Singer SDK taps), dbt-postgres ~1.10 (invoked as `python -m dbt.cli.main`), Meltano, PostgreSQL.
## Global Constraints
- **No new external sources** (spec §5): only fields already in the `raw` schema or in files the taps already download. The one sanctioned tap change is the Ofsted MI report-card columns (spec §5, §8.4) and the legacy-KS2 year addition (same DfE performance-tables source).
- **Additive only:** never rename or drop existing mart columns; the backend maps them 1:1 in `backend/models.py`.
- **Never push to `main`.** Branch: `feat/compare-data-foundation`; PR checks must pass.
- Backend `models.py` changes belong to the follow-up backend PR, not this one.
- dbt invocation is always `python -m dbt.cli.main` (a bare `dbt` resolves to the wrong binary — see `pipeline/dags/school_data_pipeline.py:27`).
- EES suppression codes `z`/`c`/`x` must go through the `safe_numeric` macro.
- Computed benchmarks (FSM/EAL/SEN medians, disadvantaged national average) are **backend work** (spec §5) — explicitly out of scope here.
---
### Task 0: Create the branch
**Files:** none
- [ ] **Step 1:** `git checkout main && git pull && git checkout -b feat/compare-data-foundation`
---
### Task 1: Diagnostics — pin the three unknowns
The spec flags three facts we must confirm from the actual files before wiring code: (a) why `gps_expected_pct`/`science_expected_pct` are NULL in `marts.fact_ks2_national_averages` despite being mapped end-to-end; (b) what the KS2 attainment long file calls its subjects/years for 2021/22 and 2022/23 (subject-level 2022/23 is NULL in prod; school-level 2021/22 is absent); (c) the exact report-card column headers in the current Ofsted MI CSV.
**Files:**
- Create: `pipeline/scripts/diagnose_compare_gaps.py`
**Interfaces:**
- Produces: a printed findings report; Tasks 5, 6, 7 consume the confirmed column/label names. Precedent: `pipeline/scripts/diagnose_ees_ks4.py`.
- [ ] **Step 1: Write the diagnostic script**
```python
"""Diagnose the three data gaps blocking the compare-screen redesign.
Run from repo root (network access required, no DB needed):
python pipeline/scripts/diagnose_compare_gaps.py
"""
import io
import re
import sys
import zipfile
import pandas as pd
import requests
sys.path.insert(0, "pipeline/plugins/extractors/tap-uk-ees")
sys.path.insert(0, "pipeline/plugins/extractors/tap-uk-ofsted")
from tap_uk_ees.tap import ( # noqa: E402
_KS2_NATIONAL_COL_MAP,
_KS2_NATIONAL_CSV_URL,
download_release_zip,
get_all_releases,
)
from tap_uk_ofsted.tap import discover_csv_url # noqa: E402
TIMEOUT = 120
def check_national_gps_science():
print("\n=== (a) National catalogue CSV: GPS/science columns ===")
resp = requests.get(_KS2_NATIONAL_CSV_URL, timeout=TIMEOUT)
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 csv_col in ("pt_gps_exp", "pt_scita_exp", "avg_readscore", "avg_matscore", "avg_gpsscore"):
status = "PRESENT" if csv_col in df.columns else "MISSING"
print(f" {csv_col}: {status}")
gps_like = [c for c in df.columns if "gps" in c or "scita" in c or "sci" in c]
print(f" all gps/science-ish columns: {gps_like}")
nat = df[df.get("geographic_level", "").str.strip().str.lower() == "national"]
print(f" national rows time_periods: {sorted(nat['time_period'].unique())}")
# Sample the values our map would read for the latest year
latest = nat[nat["time_period"] == nat["time_period"].max()]
for csv_col, field in _KS2_NATIONAL_COL_MAP.items():
val = latest.iloc[0].get(csv_col, "<col missing>") if len(latest) else "<no row>"
print(f" {field} <- {csv_col} = {val!r}")
def check_ks2_attainment_years_subjects():
print("\n=== (b) EES KS2 attainment: years & subject labels ===")
releases = get_all_releases("key-stage-2-attainment")
print(f" releases found: {[r['time_period'] for r in releases]}")
for release in releases:
zf = download_release_zip(release["id"])
name = next((n for n in zf.namelist()
if "ks2_school_attainment_data" in n and n.endswith(".csv")), None)
if not name:
print(f" {release['time_period']}: NO school attainment CSV in ZIP")
continue
with zf.open(name) as f:
df = pd.read_csv(f, dtype=str, keep_default_na=False, nrows=200000)
years = sorted(df["time_period"].unique())
subjects = sorted(df["subject"].unique())
print(f" release {release['time_period']}: time_periods={years}")
print(f" subjects={subjects}")
def check_ofsted_report_card_columns():
print("\n=== (c) Ofsted MI CSV: report-card columns ===")
url = discover_csv_url()
print(f" MI file: {url}")
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False, nrows=5)
rc_like = [c for c in df.columns
if re.search(r"report card|inclusion|curriculum|achievement|safeguard|well.?being|governance", c, re.I)]
print(f" candidate report-card columns ({len(rc_like)}):")
for c in rc_like:
print(f" - {c!r}")
if __name__ == "__main__":
check_national_gps_science()
check_ks2_attainment_years_subjects()
check_ofsted_report_card_columns()
```
Note: if `_KS2_NATIONAL_CSV_URL` is named differently in `tap_uk_ees/tap.py` (it is defined near the `_KS2_NATIONAL_COL_MAP` around line ~490), import whatever constant holds the catalogue CSV URL.
- [ ] **Step 2: Run it and record findings**
Run: `python pipeline/scripts/diagnose_compare_gaps.py 2>&1 | tee /tmp/compare-gaps-findings.txt`
Expected: three sections printed. Paste the findings as a comment block at the bottom of the script (so they're committed evidence), e.g. `# FINDINGS 2026-07-12: pt_gps_exp MISSING (actual col: ...), 202122 present in release X, rc columns: [...]`.
- [ ] **Step 3: Commit**
```bash
git add pipeline/scripts/diagnose_compare_gaps.py
git commit -m "chore(pipeline): diagnostic for compare-screen data gaps"
```
---
### Task 2: Admissions preference detail → mart
Staging already extracts `second_preference_offers`, `third_preference_offers`, `total_offers` (`stg_ees_admissions.sql:26-29`) — the mart drops them. The cross-LA fields are declared in the tap (`all_applications_from_another_LA`, `offers_to_applicants_from_another_LA`) but not selected in staging.
**Files:**
- Modify: `pipeline/transform/models/staging/stg_ees_admissions.sql` (after line 33, in `renamed`)
- Modify: `pipeline/transform/models/marts/fact_admissions.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_admissions block, ~line 120)
**Interfaces:**
- Produces mart columns: `total_offers int`, `second_preference_offers int`, `third_preference_offers int`, `cross_la_applications int`, `cross_la_offers int`. The backend PR will map these in `FactAdmissions`.
- [ ] **Step 1: Add cross-LA columns to staging**
In `stg_ees_admissions.sql`, after the `first_preference_applications` line (line 33):
```sql
-- Cross-borough demand: applications naming this school from families
-- living in another local authority, and offers made to them.
{{ safe_numeric('"all_applications_from_another_LA"') }}::integer as cross_la_applications,
{{ safe_numeric('"offers_to_applicants_from_another_LA"') }}::integer as cross_la_offers,
```
(Quote the identifiers — the tap emits them with mixed case, same trap as `FSM_eligible_percent`, see the header comment in that file. If `dbt parse` or the DAG run later shows the raw columns are lower-cased in Postgres, drop the double quotes.)
- [ ] **Step 2: Pass everything through the mart**
Replace the full select list in `fact_admissions.sql`:
```sql
-- Mart: School admissions — one row per URN per year
select
urn,
year,
school_phase,
places_offered,
total_offers,
total_applications,
first_preference_applications,
first_preference_offers,
second_preference_offers,
third_preference_offers,
cross_la_applications,
cross_la_offers,
first_preference_offer_pct,
oversubscription_ratio,
oversubscribed,
admissions_policy
from {{ ref('stg_ees_admissions') }}
```
- [ ] **Step 3: Add schema tests**
In `_marts_schema.yml` under `fact_admissions.columns`, append:
```yaml
- name: second_preference_offers
- name: third_preference_offers
- name: cross_la_applications
- name: cross_la_offers
- name: total_offers
```
- [ ] **Step 4: Parse gate**
Run: `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .`
Expected: `Done.` with no compilation errors.
- [ ] **Step 5: Commit**
```bash
git add pipeline/transform/models/staging/stg_ees_admissions.sql pipeline/transform/models/marts/fact_admissions.sql pipeline/transform/models/marts/_marts_schema.yml
git commit -m "feat(pipeline): admissions preference breakdown and cross-LA demand in marts"
```
---
### Task 3: KS2 progress confidence intervals + writing working-towards
The tap already emits `progress_measure_lower_conf_interval`, `progress_measure_upper_conf_interval`, `working_towards_expected_standard_pupil_percent` (tap.py:203-206). The staging pivot drops them. These power the CI-based Above/Average/Below progress chips (spec §8, first-review item on statistical honesty).
**Files:**
- Modify: `pipeline/transform/models/staging/stg_ees_ks2.sql` (inside the `pivoted` CTE, next to each subject's `progress_measure_score` case, lines ~41/55/72, and in the final select ~lines 145-152)
- Modify: `pipeline/transform/models/marts/fact_ks2_performance.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_ks2_performance block, ~line 82)
**Interfaces:**
- Produces mart columns: `reading_progress_lower_ci`, `reading_progress_upper_ci`, `writing_progress_lower_ci`, `writing_progress_upper_ci`, `maths_progress_lower_ci`, `maths_progress_upper_ci` (float), `writing_working_towards_pct` (float).
- [ ] **Step 1: Add pivot cases in staging**
After the `reading_progress` case (line ~41), add:
```sql
max(case when subject = 'Reading'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_lower_conf_interval') }} end) as reading_progress_lower_ci,
max(case when subject = 'Reading'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_upper_conf_interval') }} end) as reading_progress_upper_ci,
```
After the `writing_progress` case (line ~55), add:
```sql
max(case when subject = 'Writing'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_lower_conf_interval') }} end) as writing_progress_lower_ci,
max(case when subject = 'Writing'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_upper_conf_interval') }} end) as writing_progress_upper_ci,
max(case when subject = 'Writing'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('working_towards_expected_standard_pupil_percent') }} end) as writing_working_towards_pct,
```
After the `maths_progress` case (line ~72), add:
```sql
max(case when subject = 'Maths'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_lower_conf_interval') }} end) as maths_progress_lower_ci,
max(case when subject = 'Maths'
and breakdown_topic = 'All pupils' and breakdown = 'Total'
then {{ safe_numeric('progress_measure_upper_conf_interval') }} end) as maths_progress_upper_ci,
```
Then add the seven new columns to the model's final select (next to the existing `p.reading_progress` / `p.writing_progress` / `p.maths_progress` lines ~145-152):
```sql
p.reading_progress_lower_ci,
p.reading_progress_upper_ci,
p.writing_progress_lower_ci,
p.writing_progress_upper_ci,
p.writing_working_towards_pct,
p.maths_progress_lower_ci,
p.maths_progress_upper_ci,
```
- [ ] **Step 2: Pass through the mart**
In `fact_ks2_performance.sql`, add the same seven column names to the select list immediately after the existing `maths_progress` line (this mart selects staging columns by name; match the file's existing alias style — if columns are selected bare, add them bare).
- [ ] **Step 3: Schema tests**
In `_marts_schema.yml` under `fact_ks2_performance.columns`, append the seven names (no tests beyond presence — values are legitimately NULL for 2023/24+ since progress measures ended with 2022/23, spec §4.3):
```yaml
- name: reading_progress_lower_ci
- name: reading_progress_upper_ci
- name: writing_progress_lower_ci
- name: writing_progress_upper_ci
- name: writing_working_towards_pct
- name: maths_progress_lower_ci
- name: maths_progress_upper_ci
```
- [ ] **Step 4: Parse gate**
Run: `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .`
Expected: `Done.`
- [ ] **Step 5: Commit**
```bash
git add pipeline/transform/models/staging/stg_ees_ks2.sql pipeline/transform/models/marts/fact_ks2_performance.sql pipeline/transform/models/marts/_marts_schema.yml
git commit -m "feat(pipeline): KS2 progress confidence intervals and writing working-towards"
```
---
### Task 4: KS4 — Progress 8 banding and disadvantage gaps
The tap's `ees_ks4_info` stream already declares `progress8_banding` (DfE's own "well above average … well below average" label — the ready-made secondary chip), `attainment8_diffn` and `progress8_diffn` (tap.py:338-340). Wire them through staging into the mart.
**Files:**
- Modify: `pipeline/transform/models/staging/stg_ees_ks4.sql` (the CTE that reads `ees_ks4_info` — the same one that already surfaces `sen_pct`; add three columns to its select and to the final joined select)
- Modify: `pipeline/transform/models/marts/fact_ks4_performance.sql` (add after `progress_8_upper_ci`)
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_ks4_performance block, ~line 93)
**Interfaces:**
- Produces mart columns: `progress_8_banding text`, `attainment_8_disadvantage_gap float`, `progress_8_disadvantage_gap float`.
- [ ] **Step 1: Staging — select from the info source**
In the info CTE of `stg_ees_ks4.sql` add:
```sql
nullif(trim(progress8_banding), '') as progress_8_banding,
{{ safe_numeric('attainment8_diffn') }} as attainment_8_disadvantage_gap,
{{ safe_numeric('progress8_diffn') }} as progress_8_disadvantage_gap,
```
and add the three names to the model's final select (aliased the same way the CTE's other columns are).
- [ ] **Step 2: Mart passthrough**
In `fact_ks4_performance.sql`, after the `progress_8_upper_ci,` line:
```sql
progress_8_banding,
attainment_8_disadvantage_gap,
progress_8_disadvantage_gap,
```
- [ ] **Step 3: Schema tests** — append the three names under `fact_ks4_performance.columns`, plus an accepted-values guard that tolerates NULL:
```yaml
- name: progress_8_banding
tests:
- accepted_values:
values: ['Well above average', 'Above average', 'Average', 'Below average', 'Well below average']
config:
where: "progress_8_banding is not null"
- name: attainment_8_disadvantage_gap
- name: progress_8_disadvantage_gap
```
(If the DAG run later shows different capitalisation in the data, fix the accepted values to match the data, not vice versa.)
- [ ] **Step 4: Parse gate**`cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .``Done.`
- [ ] **Step 5: Commit**
```bash
git add pipeline/transform/models/staging/stg_ees_ks4.sql pipeline/transform/models/marts/fact_ks4_performance.sql pipeline/transform/models/marts/_marts_schema.yml
git commit -m "feat(pipeline): Progress 8 banding and KS4 disadvantage gaps in marts"
```
---
### Task 5: National averages — 2015/16 row and GPS/science/scaled-score fix
Two changes. (1) `stg_ees_ks2_national.sql:34` filters `>= 201617`, which is exactly why the England line starts a year late (2015/16 RWM = 53% exists in the catalogue). (2) GPS/science expected are NULL in prod despite full end-to-end mapping — Task 1's findings say whether the catalogue CSV column names differ from `_KS2_NATIONAL_COL_MAP` (`pt_gps_exp`, `pt_scita_exp`) or whether values are suppressed at source.
**Files:**
- Modify: `pipeline/transform/models/staging/stg_ees_ks2_national.sql:34`
- Modify (conditional on Task 1 findings): `pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py` (`_KS2_NATIONAL_COL_MAP`)
**Interfaces:**
- Produces: a 201516 row in `marts.fact_ks2_national_averages`; non-NULL `gps_expected_pct`, `science_expected_pct`, `reading_avg_score`, `maths_avg_score`, `gps_avg_score` for years the DfE publishes them. Backend/frontend consume via `/api/national-averages` unchanged (additive year + newly non-NULL fields).
- [ ] **Step 1: Widen the year filter**
In `stg_ees_ks2_national.sql`, change line 34:
```sql
and cast(trim(time_period) as integer) >= 201516
```
(2015/16 was the first year of the current expected-standard tests; nothing earlier is comparable, so keep a floor.)
- [ ] **Step 2: Fix the column map per Task 1 findings**
If Task 1 reported the actual CSV column names for GPS/science/scaled scores differ, update `_KS2_NATIONAL_COL_MAP` in `tap.py` accordingly, e.g. (illustrative — use the diagnosed names):
```python
_KS2_NATIONAL_COL_MAP = {
# ... existing entries ...
"pt_gps_exp": "gps_expected_pct", # replace key with diagnosed name
"pt_scita_exp": "science_expected_pct", # replace key with diagnosed name
}
```
If Task 1 showed the columns are present but suppressed (`x`) at national level for all years, instead delete the two entries from the map, delete the corresponding lines from `stg_ees_ks2_national.sql` and `fact_ks2_national_averages.sql`, and record in the PR description that GPS/science England ticks stay "not in dataset" (the mockups already carry that caveat).
- [ ] **Step 3: Parse gate**`cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .``Done.`
- [ ] **Step 4: Commit**
```bash
git add pipeline/transform/models/staging/stg_ees_ks2_national.sql pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py
git commit -m "fix(pipeline): include 2015/16 national averages; fix GPS/science national mapping"
```
---
### Task 6: Legacy KS2 — load the 2021/22 school-level year
School-level 2021/22 exists in DfE performance-tables archives (same source as the four legacy years already loaded) but in neither our legacy config (stops at 201819, `pipeline/meltano.yml:33-37`) nor EES (starts 2022/23) — unless Task 1's finding (b) showed an EES release carrying 202122, in which case skip this task and note why in the PR.
The legacy URLs point at the self-hosted filebrowser (`10.0.1.224:8081`) — **the 2021/22 DfE archive must be uploaded there first; this is the one human dependency in this plan.**
**Files:**
- Modify: `pipeline/meltano.yml` (legacy_ks2_urls block, line ~33)
**Interfaces:**
- Produces: `raw.legacy_ks2` rows with `year = '202122'`, flowing through `stg_legacy_ks2``fact_ks2_performance` unchanged (the stream maps old column names already; 2021/22 CSVs use the same `PTRWM_EXP`-style headers as 2018/19).
- [ ] **Step 1: Verify the 2021/22 CSV headers match `_LEGACY_KS2_COLUMN_MAP`**
Download the DfE 2021/22 KS2 revised archive (gov.uk "Compare School Performance data download": 2021-2022 all-schools ZIP), then:
Run: `python -c "import zipfile,io,pandas as pd; zf=zipfile.ZipFile('/path/to/2021-2022.zip'); n=[x for x in zf.namelist() if 'ks2final' in x.lower() and x.endswith('.csv')][0]; df=pd.read_csv(zf.open(n), dtype=str, nrows=5); import sys; sys.path.insert(0,'pipeline/plugins/extractors/tap-uk-ees'); from tap_uk_ees.tap import _LEGACY_KS2_COLUMN_MAP as m; missing=[c for c in m if c not in df.columns]; print('missing legacy columns:', missing)"`
Expected: `missing legacy columns: []` (progress columns `READPROG` etc. may legitimately be missing/blank in 2021/22 — acceptable, they load as NULL).
- [ ] **Step 2: Upload the archive to the filebrowser and add the config entry**
In `pipeline/meltano.yml` under `legacy_ks2_urls`, add (with the real share URL from the filebrowser upload):
```yaml
"202122": "http://10.0.1.224:8081/filebrowser/api/public/dl/<SHARE_ID>?inline=true"
```
- [ ] **Step 3: Commit**
```bash
git add pipeline/meltano.yml
git commit -m "feat(pipeline): load 2021/22 school-level KS2 from legacy performance tables"
```
- [ ] **Step 4 (only if Task 1(b) showed 2022/23 subject labels differ):** widen the subject matchers in `stg_ees_ks2.sql` the same way GPS already is (`subject ilike '%grammar%' or subject = 'GPS'`), e.g. `subject in ('Reading', 'reading')` → use the diagnosed labels. Parse-gate and commit as `fix(pipeline): match 2022/23 KS2 subject labels`.
---
### Task 7: Ofsted report-card columns (rc_*)
Resolves the tap TODO (`stg_ofsted_inspections.sql:37`). The marts/backed columns already exist as stubs; this wires real values. Uses Task 1(c)'s confirmed MI column names — the candidates below follow the MI file's existing naming style and must be corrected against the diagnostic output.
**Files:**
- Modify: `pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py` (COLUMN_PRIORITY ~line 19-72, schema ~line 100-114)
- Create: `pipeline/transform/macros/parse_report_card_grade.sql`
- Modify: `pipeline/transform/models/staging/stg_ofsted_inspections.sql:36-46`
**Interfaces:**
- Produces mart columns (already declared in `fact_ofsted_inspection`): `rc_safeguarding_met boolean`, and `rc_inclusion``rc_sixth_form` as integers on the 5-point scale `1=Exceptional, 2=Strong standard, 3=Expected standard, 4=Needs attention/Attention needed, 5=Urgent improvement`. The backend translates codes to labels (same pattern as `gias_codes.py`), verifying wording against Ofsted's published toolkit (spec §8.4).
- [ ] **Step 1: Add tap column mappings**
In `COLUMN_PRIORITY` add (replace candidate strings with Task 1(c)'s exact headers — keep them as priority lists so older files degrade to blank):
```python
"rc_safeguarding_met": ["Report card safeguarding", "Safeguarding"],
"rc_inclusion": ["Report card inclusion", "Inclusion"],
"rc_curriculum_teaching": ["Report card curriculum and teaching", "Curriculum and teaching"],
"rc_achievement": ["Report card achievement", "Achievement"],
"rc_attendance_behaviour": ["Report card attendance and behaviour", "Attendance and behaviour"],
"rc_personal_development": ["Report card personal development and well-being", "Personal development and well-being"],
"rc_leadership_governance": ["Report card leadership and governance", "Leadership and governance"],
"rc_early_years": ["Report card early years", "Early years"],
"rc_sixth_form": ["Report card sixth form", "Sixth form"],
```
And in the stream schema (next to `report_url`, ~line 114):
```python
th.Property("rc_safeguarding_met", th.StringType),
th.Property("rc_inclusion", th.StringType),
th.Property("rc_curriculum_teaching", th.StringType),
th.Property("rc_achievement", th.StringType),
th.Property("rc_attendance_behaviour", th.StringType),
th.Property("rc_personal_development", th.StringType),
th.Property("rc_leadership_governance", th.StringType),
th.Property("rc_early_years", th.StringType),
th.Property("rc_sixth_form", th.StringType),
```
- [ ] **Step 2: Write the grade-parsing macro**
`pipeline/transform/macros/parse_report_card_grade.sql`:
```sql
{% macro parse_report_card_grade(column_name) %}
case lower(trim(nullif({{ column_name }}, 'NULL')))
when 'exceptional' then 1
when 'strong standard' then 2
when 'expected standard' then 3
when 'needs attention' then 4
when 'attention needed' then 4
when 'urgent improvement' then 5
end
{% endmacro %}
```
- [ ] **Step 3: Wire staging**
Replace `stg_ofsted_inspections.sql` lines 36-46 (the NULL stubs) with:
```sql
-- Report Card fields (post-Nov 2025 framework), 5-point scale:
-- 1 Exceptional · 2 Strong standard · 3 Expected standard
-- · 4 Needs attention · 5 Urgent improvement
(lower(trim(nullif(rc_safeguarding_met, 'NULL'))) = 'met') as rc_safeguarding_met,
{{ parse_report_card_grade('rc_inclusion') }}::integer as rc_inclusion,
{{ parse_report_card_grade('rc_curriculum_teaching') }}::integer as rc_curriculum_teaching,
{{ parse_report_card_grade('rc_achievement') }}::integer as rc_achievement,
{{ parse_report_card_grade('rc_attendance_behaviour') }}::integer as rc_attendance_behaviour,
{{ parse_report_card_grade('rc_personal_development') }}::integer as rc_personal_development,
{{ parse_report_card_grade('rc_leadership_governance') }}::integer as rc_leadership_governance,
{{ parse_report_card_grade('rc_early_years') }}::integer as rc_early_years,
{{ parse_report_card_grade('rc_sixth_form') }}::integer as rc_sixth_form,
```
Note `rc_safeguarding_met` becomes boolean (NULL when blank) — matching `fact_ofsted_inspection`'s `rc_safeguarding_met` Boolean column. If `fact_ofsted_inspection.sql` casts these columns, align its casts too (inspect that model; it currently passes the text stubs through).
- [ ] **Step 4: Parse gate + tap smoke test**
Run: `cd pipeline/transform && python -m dbt.cli.main parse --profiles-dir .``Done.`
Run: `python -c "import sys; sys.path.insert(0,'pipeline/plugins/extractors/tap-uk-ofsted'); from tap_uk_ofsted.tap import COLUMN_PRIORITY; assert 'rc_inclusion' in COLUMN_PRIORITY; print('ok')"``ok`
- [ ] **Step 5: Commit**
```bash
git add pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py pipeline/transform/macros/parse_report_card_grade.sql pipeline/transform/models/staging/stg_ofsted_inspections.sql
git commit -m "feat(pipeline): extract Ofsted report-card judgements (rc_* columns)"
```
---
### Task 8: PR + post-merge verification
**Files:** none new
- [ ] **Step 1: Push and open the PR** (Gitea — use the git credential helper + basic-auth API pattern; token-header auth 401s):
```bash
git push -u origin feat/compare-data-foundation
# then create the PR via the Gitea API with basic auth from `git credential fill`
```
PR body: link spec §5/§8, list the new mart columns, note the Task 6 human dependency (filebrowser upload) and the Task 1 findings file.
- [ ] **Step 2: After merge, verify the DAG run picked everything up**
The daily/monthly DAGs rebuild the affected models (`pipeline/dags/school_data_pipeline.py`). Spot-check via the public API (production after promotion, staging first at stx.schoolcompare.co.uk — note external /api is broken at the staging proxy, so check staging from the host):
```bash
# 2015/16 national row exists
curl -sL "https://www.schoolcompare.co.uk/api/national-averages" | python3 -c "import json,sys; d=json.load(sys.stdin); assert any(r['year']==201516 and r['primary'] for r in d['by_year']), '2015/16 missing'; print('201516 ok')"
# 2021/22 school rows exist (Barclay)
curl -sL "https://www.schoolcompare.co.uk/api/schools/138690" | python3 -c "import json,sys; d=json.load(sys.stdin); ys=[r['year'] for r in d['yearly_data']]; assert 202122 in [int(y) for y in ys], ys; print('202122 ok')"
```
(The admissions/CI/KS4/rc_* columns aren't API-visible until the backend PR maps them — verify those directly in Postgres from the pipeline host: `select count(*) from marts.fact_admissions where second_preference_offers is not null;` etc.)
- [ ] **Step 3: Update the spec** — tick off the §5 promotions this PR delivered (edit the spec's promotion list to note "landed in PR #NN") and commit to main via a docs PR or alongside the backend PR.
---
## Out of scope (next plans)
1. **Backend PR:** map new columns in `backend/models.py`, extend `/api/compare` with supplementary blocks + `national_averages`, computed benchmarks (FSM/EAL/SEN/size medians, disadvantaged national average), CI-based progress banding, report-card label translation (verify against Ofsted toolkit), Ofsted provider-page URLs, graded-vs-ungraded surfacing.
2. **Frontend PR:** rebuild `/compare` per the mockups + e2e journeys (promotion gate).
3. **Separate bug fix:** third school's series not rendering on the current production chart.
4. **Post-v1 (spec):** census ethnicity/young-carer promotion, IDACI display, attendance section, gender-split/absence tier-2 measures.
5. **Already in marts, no work needed:** KS4 EBacc entry/APS, grade 5+ English & maths, Progress 8 CIs — `fact_ks4_performance` carries them today; only the backend needs to expose them.
@@ -0,0 +1,399 @@
# Compare API Enrichment (Backend PR) 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:** Expose the PR #32 data through the API so the redesigned compare screen can be built: enrich `/api/compare` with supplementary blocks + national averages + computed benchmarks, translate Ofsted report-card codes to labels, and surface the new mart columns (spec §6, §8 of `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md`).
**Architecture:** All changes are additive API fields — existing consumers keep working. One small dbt change rides along: `fact_performance` (the combined KS2+KS4 mart the backend's `_MAIN_QUERY` reads) enumerates columns explicitly and was not extended in PR #32, so the new KS2 CI and KS4 banding columns must be threaded through it here. Everything else is backend Python: `models.py` mappings, `data_loader` query/supplementary additions, an Ofsted label dictionary (gias_codes pattern), and `/api/compare` composition.
**Tech Stack:** FastAPI, SQLAlchemy, pandas; dbt (one model); pytest via `python -m pytest backend/tests -q` (CI installs `requirements.txt pytest "httpx<0.28"`; locally use `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q`).
## Global Constraints
- **Never push to `main`.** Branch: `feat/compare-api-enrichment`.
- **Additive only** to API responses; never rename/remove existing fields (frontend + e2e depend on them).
- **Report-card scale labels are the live-sampled vocabulary** (evidence in `pipeline/scripts/diagnose_compare_gaps.py`): `1=Exceptional, 2=Strong standard, 3=Expected standard, 4=Needs attention, 5=Urgent improvement`. Never "Attention needed". Safeguarding is boolean met/not-met, never counted as a graded area.
- **Ofsted links** are always the provider page `https://reports.ofsted.gov.uk/provider/21/{urn}` (spec §5) labelled as the school's Ofsted page.
- **Benchmark provenance** (spec §8.6): computed values are "state-school average (computed from our dataset)" — the API must expose them under a `benchmarks` key, clearly separate from official `national_averages`.
- TDD: each behaviour lands with a failing test first, in `backend/tests/` following the `test_school_details.py` pattern (pandas fixture + monkeypatched `load_school_data` + `TestClient`).
- Deploy note for the PR body: the new API fields return NULL/empty until prod's DAGs have run post-promotion.
---
### Task 0: Branch
- [ ] `git checkout main && git pull && git checkout -b feat/compare-api-enrichment` (commit this plan file on the branch).
---
### Task 1: Thread PR #32 columns through `fact_performance`
**Files:**
- Modify: `pipeline/transform/models/marts/fact_performance.sql`
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (fact_performance block, if it has one — add the columns wherever the model's other columns are listed; if the model has no column list there, skip the yml)
**Interfaces:**
- Produces (for `_MAIN_QUERY` in Task 4): `ks2.*` CI columns and `ks4.progress_8_banding`, `ks4.attainment_8_disadvantage_gap`, `ks4.progress_8_disadvantage_gap` on `marts.fact_performance`.
- [ ] **Step 1:** In `fact_performance.sql`, after `ks2.reading_progress,` add `ks2.reading_progress_lower_ci,` and `ks2.reading_progress_upper_ci,`; after `ks2.writing_progress,` add `ks2.writing_progress_lower_ci,`, `ks2.writing_progress_upper_ci,`, `ks2.writing_working_towards_pct,`; after `ks2.maths_progress,` add `ks2.maths_progress_lower_ci,`, `ks2.maths_progress_upper_ci,`. In the KS4 section, after the `ks4.progress_8_upper_ci`-equivalent line (locate the Progress 8 block) add:
```sql
ks4.progress_8_banding,
ks4.attainment_8_disadvantage_gap,
ks4.progress_8_disadvantage_gap,
```
- [ ] **Step 2:** Parse gate: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` → exit 0.
- [ ] **Step 3:** Commit: `feat(pipeline): thread compare-foundation columns through fact_performance`
---
### Task 2: ORM mappings for the new mart columns
**Files:**
- Modify: `backend/models.py` (`KS2Performance` after `maths_progress`; `FactAdmissions` after `first_preference_offers`)
- Test: none (declarative mappings; covered by Task 4's query tests)
**Interfaces:**
- Produces attributes used by Task 4: `KS2Performance.reading_progress_lower_ci``maths_progress_upper_ci`, `writing_working_towards_pct` (Float); `FactAdmissions.total_offers`, `.second_preference_offers`, `.third_preference_offers`, `.cross_la_applications`, `.cross_la_offers` (Integer).
- [ ] **Step 1:** Add to `KS2Performance` (next to the existing progress columns):
```python
reading_progress_lower_ci = Column(Float)
reading_progress_upper_ci = Column(Float)
writing_progress_lower_ci = Column(Float)
writing_progress_upper_ci = Column(Float)
writing_working_towards_pct = Column(Float)
maths_progress_lower_ci = Column(Float)
maths_progress_upper_ci = Column(Float)
```
Add to `FactAdmissions` (after `first_preference_offers`):
```python
total_offers = Column(Integer)
second_preference_offers = Column(Integer)
third_preference_offers = Column(Integer)
cross_la_applications = Column(Integer)
cross_la_offers = Column(Integer)
```
(`FactOfstedInspection` already maps all `rc_*` columns with the right types — verify, don't change.)
- [ ] **Step 2:** Commit: `feat(api): map compare-foundation mart columns`
---
### Task 3: Ofsted label dictionary + provider URL (TDD)
**Files:**
- Create: `backend/ofsted_codes.py`
- Test: `backend/tests/test_ofsted_codes.py`
**Interfaces:**
- Produces for Task 4: `REPORT_CARD_GRADE_NAMES: dict[int, str]`, `report_card_labels(ofsted: dict) -> dict` (returns `{area_key: {"code": int, "label": str}}` for the non-null `rc_*` grade fields, excluding safeguarding), `ofsted_page_url(urn: int) -> str`.
- [ ] **Step 1: Failing tests**
```python
"""Report-card code translation uses the live-sampled Ofsted vocabulary
(pipeline/scripts/diagnose_compare_gaps.py TASK 7 VALUE SAMPLE):
Exceptional / Strong standard / Expected standard / Needs attention /
Urgent improvement — never the consultation draft's 'Attention needed'."""
from backend.ofsted_codes import (
REPORT_CARD_GRADE_NAMES, report_card_labels, ofsted_page_url,
)
def test_scale_is_sampled_vocabulary():
assert REPORT_CARD_GRADE_NAMES == {
1: "Exceptional",
2: "Strong standard",
3: "Expected standard",
4: "Needs attention",
5: "Urgent improvement",
}
def test_labels_only_for_populated_areas_and_never_safeguarding():
ofsted = {
"rc_achievement": 2,
"rc_inclusion": 3,
"rc_attendance_behaviour": 4,
"rc_early_years": None,
"rc_safeguarding_met": True,
"overall_effectiveness": None,
}
labels = report_card_labels(ofsted)
assert labels == {
"rc_achievement": {"code": 2, "label": "Strong standard"},
"rc_inclusion": {"code": 3, "label": "Expected standard"},
"rc_attendance_behaviour": {"code": 4, "label": "Needs attention"},
}
def test_unknown_code_is_skipped_not_crashed():
assert report_card_labels({"rc_achievement": 9}) == {}
def test_provider_url():
assert ofsted_page_url(138690) == "https://reports.ofsted.gov.uk/provider/21/138690"
```
- [ ] **Step 2:** Run `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_ofsted_codes.py -q` → FAIL (module missing).
- [ ] **Step 3: Implement `backend/ofsted_codes.py`**
```python
"""Ofsted renewed-framework (Nov 2025) report-card code translation.
Scale labels are the live-sampled vocabulary from the Ofsted MI file
(see pipeline/scripts/diagnose_compare_gaps.py, TASK 7 VALUE SAMPLE) —
verified against real data, not the consultation draft.
"""
REPORT_CARD_GRADE_NAMES = {
1: "Exceptional",
2: "Strong standard",
3: "Expected standard",
4: "Needs attention",
5: "Urgent improvement",
}
# Graded evaluation areas only — safeguarding is a separate boolean
# judgement and must never appear in grade counts or label maps.
_RC_AREA_KEYS = (
"rc_inclusion",
"rc_curriculum_teaching",
"rc_achievement",
"rc_attendance_behaviour",
"rc_personal_development",
"rc_leadership_governance",
"rc_early_years",
"rc_sixth_form",
)
def report_card_labels(ofsted: dict) -> dict:
"""{area_key: {code, label}} for populated, known-valued rc_* areas."""
out = {}
for key in _RC_AREA_KEYS:
code = ofsted.get(key)
label = REPORT_CARD_GRADE_NAMES.get(code)
if code is not None and label is not None:
out[key] = {"code": code, "label": label}
return out
def ofsted_page_url(urn: int) -> str:
"""The school's page on ofsted.gov.uk (all its reports live there —
we never deep-link an individual report; spec §5)."""
return f"https://reports.ofsted.gov.uk/provider/21/{urn}"
```
- [ ] **Step 4:** Re-run the test file → 4 passed. Run the full suite (same command, `backend/tests -q`) → all pass.
- [ ] **Step 5:** Commit: `feat(api): Ofsted report-card labels and provider-page URL`
---
### Task 4: data_loader — query columns + richer supplementary blocks (TDD)
**Files:**
- Modify: `backend/data_loader.py` (`_MAIN_QUERY` ~line 153; `get_supplementary_data` ~line 460)
- Test: `backend/tests/test_supplementary_enrichment.py`
**Interfaces:**
- `_MAIN_QUERY` additionally selects (KS2 block, after `p.maths_progress`): `p.reading_progress_lower_ci, p.reading_progress_upper_ci, p.writing_progress_lower_ci, p.writing_progress_upper_ci, p.writing_working_towards_pct, p.maths_progress_lower_ci, p.maths_progress_upper_ci`; (KS4 block, after the Progress 8 CI columns): `p.progress_8_banding, p.attainment_8_disadvantage_gap, p.progress_8_disadvantage_gap`. Note `_MAIN_QUERY_NO_SIXTH_FORM`/`_MAIN_QUERY_LEGACY_NAMES` are string-derived from `_MAIN_QUERY` (lines 259-270) and inherit automatically — verify the assertions there still hold.
- `get_supplementary_data(db, urn)["admissions"]` rows additionally carry: `total_offers`, `second_preference_offers`, `third_preference_offers`, `cross_la_applications`, `cross_la_offers` (add to `_admissions_row`).
- `get_supplementary_data(db, urn)["ofsted"]` additionally carries: `report_card` (the `report_card_labels(...)` dict, `{}` when no rc data), `ofsted_page_url`, and `grade_source`: `"graded"` when `overall_effectiveness` came from the graded column, `"ungraded_carried_forward"` when the fallback `ungraded_grade` supplied it, `None` when neither.
- [ ] **Step 1: Failing tests** — construct a fake Ofsted row object (simple `types.SimpleNamespace` with the model's attributes) and call the block-building logic via `get_supplementary_data` with a stubbed session (follow how existing tests stub the db; if none do, factor the ofsted-dict construction into a pure helper `_ofsted_block(o, urn)` and test that directly — preferred):
```python
import types
from backend.data_loader import _ofsted_block
def _row(**kw):
base = dict(
framework="RC", inspection_date=None, inspection_type=None,
overall_effectiveness=None, quality_of_education=None,
behaviour_attitudes=None, personal_development=None,
leadership_management=None, early_years_provision=None,
sixth_form_provision=None, ungraded_outcome=None, ungraded_grade=None,
rc_safeguarding_met=None, rc_inclusion=None, rc_curriculum_teaching=None,
rc_achievement=None, rc_attendance_behaviour=None,
rc_personal_development=None, rc_leadership_governance=None,
rc_early_years=None, rc_sixth_form=None, report_url=None,
)
base.update(kw)
return types.SimpleNamespace(**base)
def test_report_card_block_and_provider_url():
o = _row(rc_achievement=2, rc_inclusion=3, rc_safeguarding_met=True)
block = _ofsted_block(o, urn=100140)
assert block["report_card"]["rc_achievement"]["label"] == "Strong standard"
assert "rc_safeguarding_met" not in block["report_card"]
assert block["rc_safeguarding_met"] is True
assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140"
def test_grade_source_graded_vs_carried_forward():
assert _ofsted_block(_row(overall_effectiveness=1), urn=1)["grade_source"] == "graded"
carried = _ofsted_block(_row(ungraded_grade=2), urn=1)
assert carried["grade_source"] == "ungraded_carried_forward"
assert carried["overall_effectiveness"] == 2
assert _ofsted_block(_row(), urn=1)["grade_source"] is None
def test_admissions_row_new_fields():
from backend.data_loader import _admissions_row_dict
a = types.SimpleNamespace(
year=202627, school_phase="Primary", places_offered=80,
total_applications=185, first_preference_applications=74,
first_preference_offers=74, first_preference_offer_pct=100.0,
oversubscription_ratio=0.925, oversubscribed=False,
total_offers=80, second_preference_offers=4, third_preference_offers=2,
cross_la_applications=12, cross_la_offers=3,
)
d = _admissions_row_dict(a)
for k in ("total_offers", "second_preference_offers", "third_preference_offers",
"cross_la_applications", "cross_la_offers"):
assert d[k] == getattr(a, k)
```
- [ ] **Step 2:** Run → FAIL (helpers don't exist).
- [ ] **Step 3: Implement.** Refactor the existing inline ofsted-dict construction in `get_supplementary_data` into a module-level `_ofsted_block(o, urn)` that produces the existing keys **unchanged** plus the three new ones (`report_card` via `report_card_labels(...)` from Task 3, `ofsted_page_url` via `ofsted_page_url(urn)`, `grade_source` per the interface rule — derived from which source supplied `overall_effectiveness`). Rename/extract the local `_admissions_row` into module-level `_admissions_row_dict(a)` and append the five new fields. Add the ten new columns to `_MAIN_QUERY` exactly as the interface lists them. `get_supplementary_data` calls both helpers; its external shape gains only additive keys.
- [ ] **Step 4:** Full suite → all pass (existing `test_school_details.py` etc. must not break; if a fixture enumerates yearly-data columns, extend it with the new NaN columns as needed).
- [ ] **Step 5:** Commit: `feat(api): expose progress CIs, KS4 banding/gaps, admissions detail, report-card labels`
---
### Task 5: Computed benchmarks helper (TDD)
**Files:**
- Modify: `backend/data_loader.py` (new function)
- Test: `backend/tests/test_benchmarks.py`
**Interfaces:**
- Produces for Task 6: `compute_benchmarks(df) -> dict` — pure function over the main dataframe (latest year, state schools), shape:
```python
{
"source": "state-school average (computed from our dataset)",
"year": 202425,
"primary": {
"disadvantaged_rwm_expected_pct": 46.1, # weighted by eligible_pupils
"eal_pct": 22.3, # median
"sen_support_pct": 14.0, # median
"disadvantaged_pct": 24.8, # median (FSM6 proxy)
"median_pupils": 281, # median school size
},
"secondary": { "median_pupils": 1024, "eal_pct": ..., "sen_support_pct": ..., "disadvantaged_pct": ... },
}
```
- [ ] **Step 1: Failing tests** — build a small synthetic df (6 primary rows with known eligible_pupils/rwm_expected_disadvantaged_pct so the weighted average is hand-checkable; a couple of secondary rows flagged by non-null `attainment_8_score`), assert: weighted disadvantaged average matches hand computation (not the unweighted mean), medians ignore NaN, secondary block lacks the disadvantaged-RWM key, latest-year filtering (rows from an older year must not affect results), and empty df → `{}`.
- [ ] **Step 2:** Run → FAIL.
- [ ] **Step 3: Implement** in `data_loader.py`:
```python
def compute_benchmarks(df: pd.DataFrame) -> dict:
"""State-school benchmarks computed from our dataset (spec §5/§8.6).
These are NOT official DfE figures — consumers must label them
'state-school average (computed from our dataset)'."""
if df.empty or "year" not in df.columns:
return {}
latest_year = df["year"].max()
d = df[df["year"] == latest_year]
if d.empty:
return {}
is_secondary = d["attainment_8_score"].notna() if "attainment_8_score" in d.columns else pd.Series(False, index=d.index)
prim, sec = d[~is_secondary], d[is_secondary]
def _median(sub, col):
if col not in sub.columns:
return None
v = sub[col].median()
return round(float(v), 1) if pd.notna(v) else None
def _weighted_disadvantaged(sub):
if not {"rwm_expected_disadvantaged_pct", "eligible_pupils"} <= set(sub.columns):
return None
s = sub.dropna(subset=["rwm_expected_disadvantaged_pct", "eligible_pupils"])
if s.empty or s["eligible_pupils"].sum() == 0:
return None
w = (s["rwm_expected_disadvantaged_pct"] * s["eligible_pupils"]).sum() / s["eligible_pupils"].sum()
return round(float(w), 1)
def _block(sub, with_disadvantaged):
block = {
"eal_pct": _median(sub, "eal_pct"),
"sen_support_pct": _median(sub, "sen_support_pct"),
"disadvantaged_pct": _median(sub, "disadvantaged_pct"),
"median_pupils": int(sub["total_pupils"].median()) if "total_pupils" in sub.columns and pd.notna(sub["total_pupils"].median()) else None,
}
if with_disadvantaged:
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
return block
return {
"source": "state-school average (computed from our dataset)",
"year": int(latest_year),
"primary": _block(prim, with_disadvantaged=True),
"secondary": _block(sec, with_disadvantaged=False),
}
```
(Adapt column presence to the real df — `sen_support_pct` reaches the df via `_MAIN_QUERY`; confirm and add it there if the KS2 block doesn't already select it, mirroring Task 4's additions.)
- [ ] **Step 4:** Full suite → pass. **Step 5:** Commit: `feat(api): computed state-school benchmarks`
---
### Task 6: Enrich `/api/compare` + expose GPS/science national averages (TDD)
**Files:**
- Modify: `backend/app.py` (`compare_schools` ~line 636; `get_national_averages` ~line 730)
- Test: `backend/tests/test_compare_enrichment.py`
**Interfaces (response additions, all additive):**
- `/api/compare` top level gains: `"national_averages"` (same payload the `/api/national-averages` endpoint returns — extract the endpoint body into a helper `_national_averages_payload(df)` and reuse; do not duplicate the logic) and `"benchmarks"` (Task 5's `compute_benchmarks(df)`).
- Each `comparison[urn]` gains: `"ofsted"`, `"census"`, `"admissions"`, `"admissions_history"`, `"deprivation"` from `get_supplementary_data` (one `SessionLocal()` for the whole request, closed in `finally`; on exception the five keys are `None`/`[]` — mirror the detail endpoint's defensive pattern at app.py:583-590).
- `get_national_averages`' KS2 metric list gains `"gps_expected_pct", "gps_high_pct", "science_expected_pct"` so the England ticks for GPS/science flow once the data exists.
- [ ] **Step 1: Failing tests** — monkeypatch `load_school_data` with a two-school primary df (reuse/extend the fixture style of `test_school_details.py`) and monkeypatch `get_supplementary_data` to a canned dict; assert on `TestClient(app).get("/api/compare?urns=...")`:
- response keeps the existing shape (`comparison[urn]["school_info"]["rwm_expected_pct"]` etc.),
- each school gains the five supplementary keys (canned values round-tripped),
- top-level `national_averages` and `benchmarks` present; `benchmarks["source"]` is the exact provenance string,
- a supplementary-layer exception (monkeypatched to raise) degrades to `ofsted: None` etc. with HTTP 200,
- `/api/national-averages` includes `gps_expected_pct` in the primary block when the df/national table provides it (monkeypatch the national-averages source the endpoint reads).
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement per the interfaces. **Step 4:** Full suite → pass.
- [ ] **Step 5:** Commit: `feat(api): compare endpoint carries supplementary blocks, national averages and benchmarks`
---
### Task 7: PR + verification
- [ ] **Step 1:** Full suite one more time + `uv run --with pyyaml python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/deploy.yml'))"` sanity is NOT needed (no workflow changes) — instead run the dbt parse gate again (Task 1 file).
- [ ] **Step 2:** Push, open PR via the Gitea API (credential-helper basic auth). PR body: the new response shapes (one JSON sketch), the reused-not-duplicated national-averages helper, the provenance rule for benchmarks, deploy note (fields NULL until prod DAGs run post-promotion), and that no e2e change is needed (no user-facing behaviour changes — the compare UI still reads the old fields; the frontend PR carries the journey updates).
- [ ] **Step 3:** After merge + staging deploy: `curl -s https://stx.schoolcompare.co.uk/api/compare?urns=138690,100140 | python3 -m json.tool | head -80` — verify the new keys and that `benchmarks.primary.disadvantaged_rwm_expected_pct` is plausible (~45-47). Verify `/api/national-averages` now carries `gps_expected_pct`/`science_expected_pct` (values or honest nulls if DfE suppresses them at national level).
---
## Out of scope
- Frontend rebuild + e2e journeys (next PR — consumes everything this PR exposes).
- `schemas.py` METRIC_DEFINITIONS additions for the trends picker (frontend PR decides which of the new columns become picker metrics).
- CI-based progress banding logic (frontend computes Above/Average/Below from the CI columns; historical years only).
@@ -0,0 +1,287 @@
# Compare Screen Frontend Rebuild 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:** Rebuild `/compare` in the Next.js app to match the approved mockups — parent-first sections (At a glance / Ofsted / Academics / Getting a place / Who goes there / Explore trends), England-average anchoring with provenance-correct labels, mobile-first measure-first layout — consuming the enriched `/api/compare` payload from PR #34, with e2e journeys updated in the same PR (they are the promotion gate).
**Architecture:** `ComparisonView` becomes an assembly of section components fed by one enriched fetch. All comprehension rules from the two expert reviews live in a pure, jest-tested module (`lib/compareLogic.ts`) — components stay presentational. The mockups are committed at `docs/superpowers/specs/mockups/compare-desktop.html` and `compare-mobile.html`: **all user-facing copy (labels, tooltips, chips, footnote wording) is taken verbatim from them** — they carry two rounds of education-expert review; do not paraphrase.
**Tech Stack:** Next.js (app router, SSR page + client view), CSS modules, Chart.js (existing `ComparisonChart`), Jest (`npm test` in `nextjs-app/`), Playwright e2e (`e2e/`).
## Global Constraints
- **Never push to `main`.** Branch: `feat/compare-frontend-rebuild`.
- **Copy is expert-reviewed:** take it verbatim from the committed mockups. Binding rules (spec §8): Ofsted scale labels come from the API's `report_card[..].label` (never hardcode area labels beyond the mockups'); official DfE numbers say "England average", computed ones say "state-school average (computed from our dataset)"; the 2021/22 chart gap note says "DfE didn't publish school-level figures for 2021/22"; never derive an overall grade from report-card areas; safeguarding never counts as a graded area; "Latest Ofsted inspection", "EHC plans", "at or above capacity", "Over 1 in 4", "first choice (officially 'first preference')".
- **Mobile-first:** the measure-first stacked layout (mobile mockup) is the base CSS; the desktop label-column grid is the `min-width` enhancement.
- **URL contract unchanged:** `?urns=` (and `metric=` now scoped to Explore trends) keep working; share flow, `useComparison` basket, phase tabs, and `compare_viewed`/`compare_metric_changed` analytics events are preserved.
- **Do not run a local server** (CLAUDE.md); verification = jest + `tsc` + the e2e suite against staging after merge. e2e must pass on **staging data** — remember staging has partial history: assert against the *latest* year, never oldest.
- Existing `/api/compare` consumers elsewhere in the app (SchoolDetail links, toasts) must not break — the response is additive, and this PR only rewrites the compare page's own components.
- **Post-v1 (do not build):** IDACI, attendance section, gender-split/absence tier-2 measures, finance (spec §4).
---
### Task 0: Branch + design sources
- [ ] `git checkout main && git pull && git checkout -b feat/compare-frontend-rebuild`
- [ ] The mockups and this plan are already in the working tree (`docs/superpowers/specs/mockups/compare-{desktop,mobile}.html`) — commit them: `docs: compare mockups as frontend design source + rebuild plan`
---
### Task 1: Types for the enriched payload
**Files:**
- Modify: `nextjs-app/lib/types.ts` (extend `SchoolResult`, `ComparisonData`, `ComparisonResponse` — located around lines 293-314)
**Interfaces (produced for every later task):**
```ts
export interface ReportCardEntry { code: number; label: string; }
export interface OfstedBlock {
framework: string | null;
inspection_date: string | null;
inspection_type: string | null;
overall_effectiveness: number | null;
grade_source: 'graded' | 'ungraded_carried_forward' | null;
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;
rc_safeguarding_met: boolean | null;
report_card: Record<string, ReportCardEntry>;
ofsted_page_url: string;
report_url: string | null;
}
export interface CensusBlock {
year: number | null; total_pupils: number | null;
female_pupils: number | null; male_pupils: number | null;
fsm_pct: number | null; eal_pct: number | null;
}
export interface AdmissionsRow {
year: number; school_phase: string | null;
places_offered: number | null; total_applications: number | null;
first_preference_applications: number | null; first_preference_offers: number | null;
first_preference_offer_pct: number | null; oversubscription_ratio: number | null;
oversubscribed: boolean | null;
total_offers: number | null; second_preference_offers: number | null;
third_preference_offers: number | null;
cross_la_applications: number | null; cross_la_offers: number | null;
}
export interface DeprivationBlock {
lsoa_code: string | null; idaci_score: number | null; idaci_decile: number | null;
}
export interface BenchmarkBlock {
eal_pct: number | null; sen_support_pct: number | null;
disadvantaged_pct: number | null; median_pupils: number | null;
disadvantaged_rwm_expected_pct?: number | null;
}
export interface Benchmarks {
source: string; year: number;
primary: BenchmarkBlock; secondary: BenchmarkBlock;
}
export interface NationalAverages {
year: number;
primary: Record<string, number>;
secondary: Record<string, number>;
by_year: Array<{ year: number; primary: Record<string, number>; secondary: Record<string, number> }>;
}
```
- [ ] **Step 1:** Add the interfaces above; extend `ComparisonData` with optional `ofsted?: OfstedBlock | null; census?: CensusBlock | null; admissions?: AdmissionsRow | null; admissions_history?: AdmissionsRow[]; deprivation?: DeprivationBlock | null;` and `ComparisonResponse` with `national_averages?: NationalAverages; benchmarks?: Benchmarks;` (optional so the UI degrades on an old backend). Extend `SchoolResult` with the ten new yearly columns (`reading_progress_lower_ci``maths_progress_upper_ci`, `writing_working_towards_pct`, `progress_8_banding: string | null`, `attainment_8_disadvantage_gap`, `progress_8_disadvantage_gap`).
- [ ] **Step 2:** `cd nextjs-app && npx tsc --noEmit` → clean. Commit: `feat(compare): types for enriched comparison payload`
---
### Task 2: `lib/compareLogic.ts` — the comprehension rules, jest-tested
**Files:**
- Create: `nextjs-app/lib/compareLogic.ts`
- Test: `nextjs-app/__tests__/lib/compareLogic.test.ts`
**Interfaces (produced):**
```ts
export type Verdict = 'above' | 'close' | 'below';
export function verdict(value: number, anchor: number, tolerance?: number): Verdict; // default tolerance 2pp
// Report-card summary per spec §4.2: count graded areas per label (best
// first), NAME any 'Needs attention'/'Urgent improvement' area, safeguarding
// separate, "No areas need attention" reassurance when applicable.
export interface ReportCardSummary {
counts: Array<{ label: string; count: number }>; // best grade first
problems: Array<{ areaLabel: string; label: string }>; // named, never counted-away
safeguarding: 'met' | 'not_met' | null;
allClear: boolean;
}
export function summariseReportCard(ofsted: OfstedBlock): ReportCardSummary;
// One display model for all three inspection regimes.
export type OfstedDisplay =
| { kind: 'none' }
| { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false }
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
| { kind: 'report_card'; summary: ReportCardSummary };
export function ofstedDisplay(ofsted: OfstedBlock | null | undefined): OfstedDisplay;
export const OFSTED_LEGACY_GRADES: Record<number, string>; // 1 Outstanding, 2 Good, 3 Requires improvement, 4 Inadequate
// Human-readable area label from an rc_ key: 'rc_attendance_behaviour' →
// 'Attendance & behaviour' (mapping table copied from the mockups' area rows).
export function rcAreaLabel(key: string): string;
// Admissions, one consistent chip metric (first-preference success).
export interface AdmissionsSummary {
firstPrefPct: number | null;
chip: { tone: 'good' | 'warn' | 'neutral'; text: string } | null; // "97% of first choices offered" / "Over 1 in 4 first choices missed out" wording per mockups
interest: string | null; // "Named on 457 forms · 180 places"
}
export function summariseAdmissions(a: AdmissionsRow | null | undefined): AdmissionsSummary;
// CI-based progress band for historical years (null when no CI published).
export function progressBand(score: number | null, lower: number | null, upper: number | null):
'above' | 'average' | 'below' | null; // CI entirely >0 → above; entirely <0 → below; straddles → average
// Dot-strip geometry (used by the DotStrip component; pure for testing).
export interface StripPoint { pos: number; labelAbove: boolean; value: number; schoolIndex: number; }
export function stripPositions(values: Array<number | null>, min: number, max: number): StripPoint[];
// pos = (v-min)/(max-min)*100 clamped 0..100; labels within 4% of range of a
// lower neighbour flip above (the mockups' collision nudge).
```
- [ ] **Step 1: Failing tests** covering, at minimum:
- `summariseReportCard`: 4 Strong + 2 Expected + 1 Needs-attention + safeguarding met → counts `[Strong standard×4, Expected standard×2]`, `problems=[{areaLabel:'Attendance & behaviour', label:'Needs attention'}]`, `allClear=false`; safeguarding NEVER in counts; all-Expected+met → `allClear=true`; labels come from the input's `.label` (assert the function never invents "Attention needed").
- `ofstedDisplay`: report_card present → `kind:'report_card'` even if a legacy grade also exists; `grade_source:'ungraded_carried_forward'``carriedForward:true`; null → `'none'`.
- `summariseAdmissions`: 73% → warn chip text `Over 1 in 4 first choices missed out`; 97% → good chip `97% of first choices offered`; 100% → `All first choices offered`; interest string `Named on 342 forms · 120 places`; nulls → null chip.
- `progressBand`: (1.2, 0.4, 2.0)→above; (-1.2, -2.0, -0.4)→below; (0.3, -0.5, 1.1)→average; missing CI → null.
- `stripPositions`: 100120 domain maps 106→30; values 91 and 92 on 0100 → second label flips above; nulls skipped.
- `verdict`: 87 vs 62 → above; 61 vs 62 → close (within 2pp); 40 vs 62 → below.
- [ ] **Step 2:** `cd nextjs-app && npm test -- compareLogic` → FAIL. **Step 3:** implement. **Step 4:** pass + `tsc` clean. **Step 5:** Commit: `feat(compare): comprehension logic (report cards, admissions, verdicts, strips)`
---
### Task 3: `DotStrip` component
**Files:**
- Create: `nextjs-app/components/DotStrip.tsx`, `nextjs-app/components/DotStrip.module.css`
**Interfaces:**
```ts
export interface DotStripProps {
label: string;
values: Array<number | null>; // one per school, school order = chart colour order
anchor?: { value: number; label: string } | null; // e.g. {62, "England 62%"} — omit when benchmark absent
min?: number; max?: number; // default 0..100
unit?: string; // default '%'
tip?: string; // title tooltip on the label
note?: string; // e.g. "(teacher-assessed)" suffix handled by caller in label
}
```
- [ ] Render per the mockups' `.strip-row` anatomy: label row, 4px track, England tick + tick label, 16px dots coloured by `CHART_COLORS[index]` with white ring, value labels below (flipped above on collision via `stripPositions`). `role="img"` + `aria-label` enumerating anchor and each school's value (copy the aria pattern from the mockups). CSS module mirrors the mockup styles using the app's CSS variables (`--border-light`, `--text-muted`, etc.).
- [ ] Jest: render with `@testing-library/react` (already configured — see `__tests__/components/SecondarySchoolRow.test.tsx` for the harness pattern): asserts aria-label content, tick present when anchor given, absent otherwise.
- [ ] Commit: `feat(compare): DotStrip with England-average anchor`
---
### Task 4: Section components — At a glance, Ofsted, Getting a place, Who goes there
**Files:**
- Create: `nextjs-app/components/compare/CompareAtAGlance.tsx` (+ `.module.css`)
- Create: `nextjs-app/components/compare/CompareOfsted.tsx`
- Create: `nextjs-app/components/compare/CompareAdmissions.tsx`
- Create: `nextjs-app/components/compare/CompareCommunity.tsx`
- Create: `nextjs-app/components/compare/compareSections.module.css` (shared measure-first grid)
- Test: `nextjs-app/__tests__/components/CompareOfsted.test.tsx`
**Shared layout contract (all four):** props `{ schools: School[]; data: Record<string, ComparisonData>; benchmarks?: Benchmarks; nationalAverages?: NationalAverages }`. Base CSS is the mobile mockup's measure-first stack (`.measure` card → `.srow` per school with colour dot + short name + value + chip + note); at `min-width: 761px` it becomes the desktop mockup's grid (200px row-label column + one column per school). Section headers use the existing `.section-title` idiom; every section carries its mockup "how" line verbatim.
**Content per section = the mockups, row for row.** Structure/tone rules already encoded in Task 2's helpers:
- *At a glance*: Latest Ofsted inspection row (badge via `ofstedDisplay`; report-card case renders `ReportCardSummary` chips — counts best-first + named problem chips + safeguarding line); expected-standard row (big % + `verdict` chip vs `national_averages.primary.rwm_expected_pct`, small "England average N%"); Getting a place row (chip from `summariseAdmissions`, note = `interest`); Size row (pupils + "at or above capacity"/"N% full" from census/capacity, vs `benchmarks.*.median_pupils` for "larger/smaller than average" phrasing).
- *Ofsted*: the section's `how` paragraph (regime explanation + non-comparability + "Expected standard" disambiguation) verbatim from the desktop mockup; Result row; Inspected row (date + "4+ years ago" chip when >4y, computed from `inspection_date`); Judgement detail row — **one chip-list grammar for both regimes** (legacy subgrades via `OFSTED_LEGACY_GRADES`; report card via `report_card` labels; "We don't hold area-by-area detail for this inspection" when neither); Ofsted page row linking `ofsted_page_url` ("<Name>'s Ofsted page →").
- *Getting a place*: `how` paragraph (first preference/equal preference/offer-day caveats) verbatim; Interest row; first-choice success row with mini bar; "What this means" row (distance note: "check the school's admission criteria (for most non-faith primaries, distance decides)" only when oversubscribed).
- *Who goes there*: pupils-on-roll (census + capacity), girls/boys, FSM (chip vs `benchmarks` with "state-school average" wording), EAL, SEN (tooltip incl. "EHC plans" + specialist-provision note), faith, ages · nursery, run by (trust name or "<LA> council").
- [ ] **Step 1:** Failing jest test for `CompareOfsted` (the riskiest): given one graded school, one carried-forward, one report-card school → asserts the three Result cells ("Outstanding" badge; badge + carried-forward marker; "Report card" + no invented overall grade), the chip-list judgement rows, and the comparability note appearing only for the mixed case.
- [ ] **Step 2-4:** Implement all four sections; test passes; `tsc` clean; `npm test` full suite green.
- [ ] **Step 5:** Commit: `feat(compare): at-a-glance, Ofsted, admissions and community sections`
---
### Task 5: `CompareAcademics` — strips + More measures
**Files:**
- Create: `nextjs-app/components/compare/CompareAcademics.tsx`
- Test: extend `nextjs-app/__tests__/lib/compareLogic.test.ts` with the metric-extraction helper below
**Interfaces:**
- Add to `compareLogic.ts`: `latestValues(data, urns, metricKey) => Array<number|null>` (latest non-null yearly value per school) — tested.
- [ ] Tier 1 strips (always visible), each a `DotStrip` with the England anchor from `national_averages.primary`: RWM expected, Reading, Writing, Maths, "Working at a higher standard than expected" (tooltip: composition sentence from the mockups). Section `how` line: "tests and teacher assessments … writing is assessed by teachers, not tested" verbatim.
- [ ] Tier 2 `<details>` "More measures — grammar, punctuation & spelling, science, average scaled scores": GPS + Science (teacher-assessed, tooltip verbatim) with anchors from `national_averages` **when present, no tick + honest note when null**; scaled scores (reading/maths/GPS) on `min=100 max=120` with the mockups' window caption.
- [ ] Equity row: disadvantaged pupils' RWM per school + chip vs `benchmarks.primary.disadvantaged_rwm_expected_pct` with the "state-school average" wording and small-cohort tooltip verbatim.
- [ ] Secondary phase variant (when active phase is secondary): tier-1 rows are Attainment 8 (anchor `national_averages.secondary.attainment_8_score`), Progress 8 banding (chip showing `progress_8_banding` verbatim — DfE's own label), grade 5+ English & maths %; tier-2: EBacc entry/APS. Measure-first rows (no strips needed for banding).
- [ ] `npm test` + `tsc`; commit: `feat(compare): academics strips with England anchors and More measures`
---
### Task 6: Trends explorer — England line, gap-honest axis, series bug
**Files:**
- Modify: `nextjs-app/components/ComparisonChart.tsx`
- Create: `nextjs-app/components/compare/TrendsExplorer.tsx`
- Test: `nextjs-app/__tests__/components/ComparisonChart.test.tsx`
- [ ] **Step 1 (bug first): root-cause the missing third series** seen on production (3 schools in table, 2 lines on chart). Write a failing jest test: 3 schools whose `yearly_data` year values are floats (`202425.0`) vs the labels array — the suspect is the year-matching in `ComparisonChart.tsx:69` (`years.map(...)` built from school 1 only + strict equality against other schools' years). Fix so every school's series renders and years are the union of all schools' years, sorted.
- [ ] **Step 2:** Add optional `nationalByYear?: Record<number, number|null>` prop → dashed grey "England average" dataset (colour `--text-muted`, `borderDash:[5,4]`, no fill, `spanGaps:false`).
- [ ] **Step 3:** Gap honesty: x-axis category labels include 2019/20 and 2020/21 as empty slots (band label "tests cancelled 2019/202020/21" via a Chart.js annotation-free approach: two category ticks with all-null data and a subtitle note under the chart, copy verbatim: the chart footnote "DfE didn't publish school-level figures for 2021/22" appears when the metric is a KS2 measure and 2021/22 school values are null while the England value exists). `spanGaps:false` on school datasets so dataset gaps break lines.
- [ ] **Step 4:** `TrendsExplorer` wraps the grouped metric picker (existing optgroup structure and `metrics` from `/api/metrics`, existing analytics event) + the chart + the existing year-by-year table, inside a collapsed-by-default `<details>` ("Explore trends"). Progress metrics annotate cells with `progressBand` chips for years where CIs exist.
- [ ] Tests pass; commit: `feat(compare): trends explorer with England line; fix missing series`
---
### Task 7: Assemble the new `ComparisonView`
**Files:**
- Rewrite: `nextjs-app/components/ComparisonView.tsx` (+ its `.module.css`)
- Modify: `nextjs-app/app/compare/page.tsx` metadata description (mention Ofsted/admissions, not just KS2)
- [ ] Preserve intact: `useComparison` basket seeding/URL sync (lines 76-122 of the current file), share handler, phase tabs + auto-detection, `compare_viewed` analytics, empty states, `SchoolSearchModal`, max-4-visible column scroll. Replace the metric-picker/chart/table body with the section stack: sticky school chip bar (mockup `.school-bar`) → `CompareAtAGlance``CompareOfsted``CompareAcademics``CompareAdmissions``CompareCommunity``TrendsExplorer`. The page-level `metric` URL param now initialises `TrendsExplorer`'s picker only.
- [ ] Top-of-page subtitle + sources footnote verbatim from the mockups (minus the "Mockup" banner), including the suppression rule sentence and provenance sentence.
- [ ] `npm test` full suite + `tsc` clean. Commit: `feat(compare): parent-first compare screen assembly`
---
### Task 8: e2e journeys (the promotion gate)
**Files:**
- Modify: `e2e/tests/journeys.spec.ts` (the two compare tests, lines ~141-215; extend, don't delete coverage)
- [ ] Update 'comparing two schools shows both side by side': after loading `/compare?urns=…` assert the new section headings (`At a glance`, `Ofsted inspection`, `How children do academically`, `Getting a place`, `Who goes there`, `Explore trends`), both school names in the sticky bar, at least one England-average tick label (`text=/England \d+%/`), and one provenance string `state-school average` somewhere (benchmarks row). Data-invariant style — no exact numbers (staging data shifts; use latest-year values only).
- [ ] Update the mobile test: 390px viewport, assert measure-first stacking (a `.measure`-card contains all selected school names within one card) and that the trends chart container scrolls (`overflow-x`).
- [ ] Add a report-card presence-agnostic assertion: the Ofsted section renders either a grade badge or "Report card" without an overall grade — i.e. never both an overall-grade badge AND report-card chips for the same school.
- [ ] Run against staging from the host if reachable (`cd e2e && BASE_URL=https://stx.schoolcompare.co.uk npx playwright test -g "compar"`) — staging still runs the OLD UI until this PR merges, so expect failures locally; the authoritative run is the Stage pipeline post-merge. Still commit only after jest+tsc are green.
- [ ] Commit: `test(e2e): compare journeys for the parent-first redesign`
---
### Task 9: PR + post-merge verification
- [ ] Full gates: `cd nextjs-app && npm test && npx tsc --noEmit`.
- [ ] Push; open PR via Gitea API (credential-helper basic auth). PR body: before/after summary, link to mockups + spec §4/§8, the copy-verbatim rule, the fixed third-series bug, deploy note (needs PR #34's API on the same environment — merge order: #34 first), and that the e2e suite is the staging gate.
- [ ] Post-merge: watch the Stage pipeline — its e2e run against staging is the real verification. Then the human tests staging and promotes (two-stage model). Update memory: compare redesign shipped to staging.
---
## Out of scope
- IDACI / attendance / gender-absence / finance (post-v1, spec §4).
- Backend changes of any kind (PR #34 must merge first).
- Chart palette overhaul beyond the England-line addition (`CHART_COLORS` swap to the validated trio is a candidate follow-up, flagged not included — it affects every chart in the app).
@@ -0,0 +1,275 @@
# Staged Production Promotion (Manual Gate) 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:** Merging a PR deploys to staging only; production deployment requires a second, explicit human approval after manual testing on staging.
**Architecture:** Split the existing single `deploy.yml` pipeline in two. The push-to-main workflow keeps build → staging deploy → e2e gate and **stops there**. A new `promote.yml` runs only on `workflow_dispatch` (the "Run workflow" button in Gitea's Actions UI, supported on this server — Gitea 1.26.4): it verifies the chosen commit passed the staging e2e gate, retags its `:sha-*` images to `:prod` (keeping `:prod-previous` for rollback), and triggers the Portainer prod webhook. Promotion granularity is a main-branch commit: staging always runs the latest main, so you approve a *state of main*, not an individual PR.
**Tech Stack:** Gitea Actions (1.26.4), Docker buildx imagetools, Portainer webhooks, Gitea commit-status API.
## Global Constraints
- **Never push to `main` directly** — this change itself goes through a PR (`chore/staged-prod-promotion` branch).
- Existing image tagging scheme is unchanged: `type=sha` (e.g. `sha-6f925ab`) + `:staging`; promotion still retags `:sha-*``:prod` with `:prod-previous` kept as the rollback pointer.
- The e2e journeys remain a **hard gate before human testing** (a red staging never reaches the promote button) and the promote workflow must refuse to promote a commit whose staging e2e did not succeed.
- Secrets already exist and are reused: `REGISTRY_TOKEN` (also a Gitea API token), `PORTAINER_STAGING_WEBHOOK`, `PORTAINER_PROD_WEBHOOK`, `STAGING_BASE_URL`, `PROD_BASE_URL`.
- Staging quirk (memory): external `/api` is broken at the staging proxy — manual API testing happens from the host, not through stx.schoolcompare.co.uk; note it in the runbook, don't try to fix it in this plan.
## Considered approaches (context for the reviewer)
1. **Manual `workflow_dispatch` promote workflow (chosen).** Native on Gitea 1.26; the second approval is clicking "Run workflow" (or one API call) after testing staging. Least machinery, auditable via the Actions run history.
2. *Tag-driven promotion* (`push: tags: promote-*`): works on any Gitea version; approval = pushing a tag. Slightly more scriptable, less discoverable; kept as documented fallback only.
3. *GitOps `production` branch + promotion PR:* approval literally reuses the PR-review UI, but adds a second long-lived branch to keep in sync — too much ceremony for a solo project. Rejected.
---
### Task 0: Branch
- [ ] `git checkout main && git pull && git checkout -b chore/staged-prod-promotion`
---
### Task 1: Stop the push-to-main workflow after the e2e gate
**Files:**
- Modify: `.gitea/workflows/deploy.yml`
**Interfaces:**
- Produces: images tagged `:sha-<short>` + `:staging` (unchanged), a green `E2E Journeys against Staging` commit status that Task 2's promote workflow checks by name. **Do not rename the `e2e-staging` job's `name:` without updating Task 2's status check.**
- [ ] **Step 1: Remove the auto-promotion**
In `.gitea/workflows/deploy.yml`:
1. Change line 1 to: `name: Stage (build -> staging -> E2E gate)`
2. Delete the entire `promote-prod` job (lines 196240 in the current file: from ` promote-prod:` to the end of the file).
3. Leave `build-*`, `deploy-staging`, and `e2e-staging` untouched.
- [ ] **Step 2: Sanity-check the YAML**
Run: `python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/deploy.yml')); print('yaml ok')"`
Expected: `yaml ok`
- [ ] **Step 3: Commit**
```bash
git add .gitea/workflows/deploy.yml
git commit -m "ci: stop deploy pipeline at staging; production promotion becomes manual"
```
---
### Task 2: Manual promote workflow
**Files:**
- Create: `.gitea/workflows/promote.yml`
**Interfaces:**
- Consumes: `:sha-<short>` images built by deploy.yml; the `E2E Journeys against Staging` commit status.
- Produces: `:prod` and `:prod-previous` tags; prod stack update.
- [ ] **Step 1: Write the workflow**
```yaml
name: Promote to Production (manual)
on:
workflow_dispatch:
inputs:
sha:
description: >-
Commit SHA on main to promote (full or >=7 chars).
Leave empty to promote the latest main commit.
required: false
default: ""
env:
REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs:
promote-prod:
name: Promote approved commit to Production
runs-on: ubuntu-latest
steps:
- name: Resolve target SHA
id: resolve
run: |
SHA_INPUT="${{ gitea.event.inputs.sha }}"
if [ -z "$SHA_INPUT" ]; then
SHA_INPUT="${{ gitea.sha }}"
fi
# Normalise to the full sha via the API so short inputs work
FULL_SHA=$(curl -fsS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/git/commits/${SHA_INPUT}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['sha'])")
SHORT_SHA="sha-$(echo "$FULL_SHA" | cut -c1-7)"
echo "full=$FULL_SHA" >> "$GITHUB_OUTPUT"
echo "short=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
- name: Verify the staging E2E gate passed for this commit
run: |
STATUS_JSON=$(curl -fsS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
echo "$STATUS_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
ok = [s for s in d.get('statuses', [])
if 'E2E Journeys against Staging' in s.get('context', '')
and s.get('status') == 'success']
if not ok:
print('REFUSED: no successful \"E2E Journeys against Staging\" status on this commit.')
print('Contexts found:', [s.get('context') for s in d.get('statuses', [])])
sys.exit(1)
print('E2E gate verified green for this commit.')
"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Retag approved images as prod (keeping rollback pointer)
run: |
SHORT_SHA="${{ steps.resolve.outputs.short }}"
for IMAGE in \
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
done
- name: Trigger production stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
- name: Wait for production to become healthy
run: |
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
echo "Production is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Production did not become healthy in time" >&2
exit 1
env:
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
```
Implementation notes for the engineer:
- Gitea Actions uses the GitHub-compatible `$GITHUB_OUTPUT` file for step outputs; if the runner image doesn't populate it, fall back to `$GITEA_OUTPUT` (check the runner's docs/output at first run).
- The retag step is copied verbatim from the old `promote-prod` job except the SHA comes from the resolved input instead of `gitea.sha` — behaviour for the default (empty input on latest main) is identical to before.
- If `docker buildx imagetools create` fails with "not found" for `${IMAGE}:${SHORT_SHA}`, the chosen commit predates the registry's retention or never built — the error message is the desired behaviour (refuse loudly).
- [ ] **Step 2: YAML sanity check**
Run: `python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/promote.yml')); print('yaml ok')"`
Expected: `yaml ok`
- [ ] **Step 3: Commit**
```bash
git add .gitea/workflows/promote.yml
git commit -m "ci: manual production promotion workflow with e2e-gate verification"
```
---
### Task 3: Documentation — deploy model + runbook
**Files:**
- Modify: `docs/DEPLOY.md`
- Modify: `claude.md` (the SDLC section)
- [ ] **Step 1: Rewrite the flow description in `docs/DEPLOY.md`**
Replace the staging→prod description with the new model (adapt to the file's existing structure; the substance to convey):
```markdown
## Deploy model
1. **PR → main (first approval).** Branch-protected merge; PR checks
(typecheck, tests, builds, AI review) must pass.
2. **Merge → staging (automatic).** Images are built once and tagged
`sha-<short>` + `staging`; the staging stack updates; Playwright
journeys in `e2e/` run against staging. A red e2e run means staging
is not fit for testing — fix forward before considering promotion.
3. **Manual testing on staging.** stx.schoolcompare.co.uk. Note:
external `/api` is broken at the staging proxy — exercise API
endpoints from the host.
4. **Promote → production (second approval).** Actions → "Promote to
Production (manual)" → Run workflow. Leave the SHA empty to promote
the latest main, or paste a specific commit SHA. The workflow
refuses commits whose staging e2e gate is not green, retags the
images `:prod` (keeping `:prod-previous`), and updates the prod
stack.
### Promotion granularity
Staging always runs the latest `main`. Promoting approves a *state of
main*, not a single PR — if two PRs merged since the last promotion,
they ship together. Test staging accordingly.
### Rollback
Re-run "Promote to Production (manual)" with the SHA of the last good
commit (or retag manually: `docker buildx imagetools create -t
<image>:prod <image>:prod-previous` for each of the three images, then
POST the prod Portainer webhook).
```
- [ ] **Step 2: Update the SDLC bullet in `claude.md`**
Replace the sentence "Merging to `main` deploys automatically: … retagged `:prod` and rolled out to production." with:
```markdown
- Merging to `main` deploys automatically **to staging only**: images
are built once, deployed to the staging Portainer stack, and verified
by the Playwright journeys in `e2e/`. Production is a second, manual
approval: the "Promote to Production (manual)" workflow in Gitea
Actions, run after testing the feature on staging. It refuses commits
whose staging e2e gate isn't green.
```
- [ ] **Step 3: Commit**
```bash
git add docs/DEPLOY.md claude.md
git commit -m "docs: two-stage deploy model (staging auto, production manual)"
```
---
### Task 4: PR + live validation
- [ ] **Step 1: Push and open the PR** (Gitea API with credential-helper basic auth, as usual). PR body: the new model in three lines, the rollback recipe, and a warning that between merging this PR and its first promotion run, production receives no deployments (expected).
- [ ] **Step 2: Validate after merge (human-in-the-loop):**
1. Merge this PR → confirm the `Stage (build -> staging -> E2E gate)` run goes green and **no** production deployment happens (prod image digest unchanged: `docker buildx imagetools inspect <image>:prod` before/after, or check the Portainer prod stack's last-update time).
2. Test something trivial on staging.
3. Run "Promote to Production (manual)" with the SHA empty → confirm e2e verification passes, retag happens, prod becomes healthy.
4. Negative test: run the promote workflow with a garbage SHA (e.g. `deadbeef1`) → confirm it fails at resolve/verify without touching `:prod`.
- [ ] **Step 3: Update the ledger/memory** with the new deploy model so future sessions stop assuming auto-promotion.
---
## Out of scope / future options
- Notifications when staging is ready for testing (Gitea can email on workflow completion; a webhook to ntfy/Matrix could be added later).
- Restricting who can run the promote workflow: Gitea 1.26 runs `workflow_dispatch` with the permissions of the dispatching user; for a solo repo this is already effectively restricted.
- The tag-driven fallback (`on: push: tags: promote-*`) if `workflow_dispatch` ever proves unreliable on the runner.
@@ -0,0 +1,177 @@
# Compare Screen Redesign — Expert Data Review
**Date:** 2026-07-11
**Reviewer:** subagent briefed as an English education-standards / DfE-Ofsted data expert
**Subject:** desktop + mobile compare mockups and the redesign spec
(`2026-07-11-compare-screen-redesign-design.md`)
**Status:** first-pass must-fixes applied 2026-07-12; second-pass
findings (below) applied 2026-07-12 — mockups + spec §4/§8 updated
## Must-fix
1. **COVID gap is wrong and drops a real results year.** KS2 tests were
cancelled 2019/20 and 2020/21 only; they resumed in 2021/22 with
published school-level results (England RWM ≈ 59%). The mockup charts
omit 2021/22 entirely and the tooltip claims no tests were held
2019/202021/22. Fix: add 2021/22 to axis and all series; shrink the
gap band; optionally annotate 2021/22 with DfE's post-pandemic
comparability caution.
2. **Report-card at-a-glance summary miscounts areas.** Detail list has
4 Strong / 2 Expected / 1 Attention needed + Safeguarding met, but
the summary says "3 areas Expected standard" — it counts safeguarding
as a graded area. Safeguarding is a separate binary judgement and
must be excluded from rating counts.
3. **"Where the offers went" derivation is unsound.** Places 1st-pref
offers ≠ "second or third choices": the residual can include 4th6th
preference offers (pan-London scheme) and LA-allocated children who
didn't choose the school; and offers don't necessarily equal PAN.
Use the real 2nd/3rd-preference fields being promoted from
`raw.ees_admissions`; until then drop the row.
4. **Ofsted timeline in the copy is wrong.** Overall grades were
abolished September 2024, not November 2025; Sept 2024Nov 2025
inspections kept the four key judgements without an overall grade
(ungraded inspections carried grades forward). Neither mockup shows
the interim regime, which will dominate real comparisons. Fix copy
and add an interim example.
5. **Barclay's "published an overall grade only — no area-by-area
detail" misdescribes inspections.** No inspection type does that; a
2021 graded inspection necessarily had subgrades — the gap is in our
dataset. If it was an ungraded (s8) inspection, "Outstanding" is a
carried-forward grade and should say so. Fix: "We don't hold
area-by-area detail for this inspection", and distinguish graded vs
ungraded in the data model.
## Should-fix
6. Writing is teacher assessment, not a test — "national tests and
teacher assessments"; note TA caveat on the Writing strip.
7. Verify renewed-framework wording against Ofsted's final toolkit:
likely "Needs attention" (not "Attention needed") and "Personal
development and well-being" (which otherwise collides with the
identically-named legacy judgement). Pin every label to the
published toolkit.
8. "Expected standard" now means two things on one page (Ofsted area
rating vs KS2 measure) — disambiguate in tooltips.
9. Disadvantaged row: DfE definition includes looked-after / previously
looked-after children, not just FSM6; benchmark labels inconsistent
across desktop/mobile; subgroup percentages need cohort sizes or a
volatility threshold before chips are attached.
10. "Trend, last 7 years" spans ten years; sparklines render the COVID
gap as equal spacing (the exact defect the audit criticises) and
"Improved: 52% → 87%" endpoint-cherry-picks a volatile series.
11. At-a-glance "Getting a place" uses different metrics per school
(Barclay is also oversubscribed on total preferences but shows a
green chip). Standardise on first-preference success %. Explain the
equal-preference rule; condition "living close by matters" on the
school's actual oversubscription criteria.
12. "457 applications for 180 places" = total preferences at any rank,
not head-to-head applicants; lead with first preferences vs places.
Add offers-vs-final-intake (waiting lists/appeals) caveat.
13. Elmhurst's subgrade list is likely missing Early years provision
(school has a nursery) — possible pipeline gap.
14. "Ofsted rating" label is obsolete post-Sept-2024 — use "Latest
Ofsted inspection"; check whether Oct 2021 is the latest inspection
or merely the latest graded one.
15. SEN: "EHCP plans" is redundant; 28% SEN support often indicates
resourced provision — add a note; England SEN-support ≈ 14%, not 13%.
## Nice-to-have
16. Consistent labelling of official DfE vs dataset-computed benchmarks
(and medians shouldn't be called averages inconsistently).
17. England 2015/16 RWM (53%) exists in DfE publications — the null is
a dataset gap; source it or the England line looks broken.
18. "1 in 4 first choices missed out" — actually more than 1 in 4.
19. "1,273 of 1,260 places (full)" is over capacity; capacity figures
are often stale — say "at or above capacity".
20. State the actual suppression rule (DfE: ≤5 pupils suppressed,
small numbers rounded) instead of "a handful".
21. Spec §4.3 progress chips can't exist for displayed years: KS2
progress ended with 2022/23 (no KS1 baseline) and returns
~2027/28 with the reception baseline. Make explicit in the spec.
IDACI (spec §4.5) is absent from mockups; if shipped, caveat it
describes pupils' neighbourhoods, not the school.
22. Tooltips should give the official term "first preference" alongside
the plain-English "first choice".
## Overall assessment (verbatim gist)
The bones are genuinely good by education-data standards —
England-average anchoring, explicit non-comparability messaging across
Ofsted regimes, refusal to synthesise an overall grade, time-true
x-axis, neutral FSM/EAL framing — better than most commercial
school-comparison sites. But items 15 are outright factual errors or
misdescriptions that a well-informed parent or Ofsted would catch;
the admissions section needs the most conceptual work (equal
preference, preferences-vs-applicants, offers-vs-intake). Fix 15
before user testing; the rest fold into the planned PRs.
---
# Second-pass review (2026-07-12)
Same reviewer, after the must-fixes and the new three-tier metric
exposure model were applied.
## Verification of first-pass must-fixes
- **1 (COVID/2021/22): resolved.** Time-true axis, band covers only the
cancelled years, England 58.7% consistent with official figures,
dataset gaps break lines honestly; reading/maths England series all
match published figures; RWM ≤ min(subject) checks pass.
- **2 (report-card count): resolved** — safeguarding excluded, spec §8.2.
- **3 (offers derivation): resolved** — row removed, spec §8.3 bans it.
- **4 (Ofsted timeline): resolved on desktop; mobile omits the interim
regime clause** (see finding 6).
- **5 (Barclay explanation): resolved.**
## New findings
1. **Should-fix — scaled-score strip domain contradicts caption.**
Caption says "scaled scores run 80120", strips render 100120;
truncated domain exaggerates small gaps and below-100 averages
would fall off the edge. Render 80120, or caption the 100120
window honestly and define below-100 behaviour.
2. **Should-fix — scaled-score England ticks (106/105/105) unsourced.**
Plausible but hand-entered; verify against DfE 2024/25 tables and
add loading official England scaled scores to the pipeline list
(absent from §8.1/§8.6).
3. **Should-fix — "Writing" listed under "Higher standard" in the
picker.** Writing TA outcome is "greater depth" (GDS), never
"higher standard". Label "Writing — greater depth (teacher
assessment)"; tooltip the combined higher-standard composition.
4. Nice — "grammar & punctuation" summary line drops "spelling" (GPS).
5. Nice — science is teacher-assessed (no KS2 test since 2009) and
coarse; tooltip it like writing; reconsider its tier-2 slot.
6. **Should-fix — mobile Ofsted copy skips the interim regime**
(Sept 2024Nov 2025) that desktop explains. One clause fixes it.
7. **Should-fix — benchmark provenance still inconsistent** (EAL
tooltip unsourced; FSM/disadvantaged chips vs tooltips use three
vocabularies; header note says all England averages are official).
Adopt one house style: official = "England average", computed =
"benchmark / typical state school (our dataset)". Also tighten EAL
definition to census wording ("first language known or believed to
be other than English").
8. Nice — "community primaries" distance note attached to an academy
(Elmhurst); say "non-faith primaries" or condition on policy field.
9. Nice — "Improving since 2022" → "since 2022/23".
10. Nice — England chart tooltips show decimals; §7 mandates whole
percents.
## Residual gaps not covered by spec §8
11. Spec promises IDACI-in-words, Attendance section, and tier-2
gender/absence that the mockups never show — mark post-v1 or
demonstrate, so implementation scope is unambiguous.
12. Add official England scaled-score averages to the pipeline task
list.
13. Add the writing/greater-depth terminology rule to §8.7.
## Verdict
All must-fixes genuinely resolved; the tier model is conceptually
sound ("no measure is lost", honest dataset-gap breaks, grouped
picker). Remaining issues are contained: one internal contradiction
(80120 vs 100120), one provenance inconsistency, one terminology
error (writing/GDS). With findings 13 and 67 addressed, the data
framing is fit to put in front of parents.
@@ -0,0 +1,324 @@
# Compare Screen Redesign — Audit & Design
**Date:** 2026-07-11
**Status:** Draft — awaiting review
**Scope:** `/compare` page (nextjs-app), `/api/compare` endpoint (backend)
## 1. Audit of the current screen
The current compare page (`nextjs-app/components/ComparisonView.tsx`) is a
single-metric analyst tool: a `<select>` with ~40 KS2/GCSE metrics, one
line chart over time, and a year-by-year table — all for the one selected
metric. Observed on production with 3 primary schools:
**What works**
- URL-shareable state (`?urns=…&metric=…`), native share sheet.
- Phase tabs (primary/secondary) with sensible auto-detection.
- Colour-coded school cards tied to chart series.
- Metric descriptions from `/api/metrics` (single source of truth).
**What doesn't**
1. **Performance-only.** The database already holds Ofsted inspections,
admissions/oversubscription history, pupil characteristics (FSM/EAL),
SEN, deprivation (IDACI), finance, capacity, faith, gender, trust —
none of it reaches the compare screen. `/api/compare` returns only
`yearly_data` + minimal `school_info`, while `/api/schools/{urn}`
already returns all supplementary blocks.
2. **One metric at a time.** A parent must know which of ~40 metrics
matters, select each in turn, and hold results in their head. There is
no side-by-side overview and no way to see two dimensions at once.
3. **No benchmarks.** Numbers float without anchors: is 79% RWM good?
The DB has official national averages (`fact_ks2_national_averages`)
but the page never shows them.
4. **Domain jargon untranslated.** "GPS Expected %", "Progress scores",
"RWM Combined" assume DfE literacy. The only plain-English help is one
note for progress scores.
5. **Raw numbers, no judgement support.** 87.0% vs 92.0% vs 79.0% — the
page never says "all three are well above the England average of 62%",
which is the fact a parent actually needs.
6. **Bugs/paper cuts observed:** the third school's series did not render
on the production chart despite table data (worth a separate fix);
the COVID gap (2018/19 → 2022/23) renders as equal spacing with no
annotation; table shows "87.0%" precision that implies false accuracy.
## 2. Data inventory (available vs shown)
| Domain | Source table | On detail page | On compare |
|---|---|---|---|
| KS2 attainment/progress | fact_ks2_performance | yes | **yes** (only thing shown) |
| National averages | fact_ks2_national_averages | partial | no |
| Ofsted (latest + subgrades + report-card fields) | fact_ofsted_inspection, dim_school | yes | no |
| Admissions & oversubscription (multi-year) | fact_admissions | yes | no |
| Pupil characteristics (FSM, EAL, gender split) | fact_pupil_characteristics | yes | no |
| Context (SEN, disadvantaged, stability, absence) | fact_ks2_performance | via metric picker | buried in picker |
| Deprivation (IDACI) | fact_deprivation | yes | no |
| Finance (per-pupil spend) | fact_finance | yes | no |
| School facts (capacity, faith, ages, trust, nursery, gender) | dim_school | yes | no |
| Location/distance | dim_location | map | no |
## 3. Design goals
1. **Answer parent questions, in order:** Is it a good school (Ofsted)?
Do children do well there (academics vs England)? Will my child get a
place (admissions)? What is the school like (size, community, faith)?
2. **Every number gets an anchor** — the England average, rendered as a
consistent visual tick, plus a plain-English chip
(Above / Close to / Below England average).
3. **Plain English first, jargon on demand.** Labels are questions or
sentences ("Children reaching the expected standard in reading,
writing and maths"), codes/acronyms live in tooltips.
4. **Scan whole-picture first, drill down second.** The single-metric
trend explorer survives, demoted to an "Explore trends" section at the
bottom rather than being the entire page.
## 4. Proposed structure
Columns = schools (max 4 visible on desktop, horizontal scroll beyond),
rows = dimensions. Sticky compact school header keeps column identity
while scrolling. Sections, in order:
1. **At a glance** — verdict row per school: Ofsted badge, headline
attainment vs England (dot strip + chip), oversubscription chip,
size, distance (when a location is set).
2. **Ofsted inspection** — must handle all three inspection regimes,
which will coexist in comparisons for years:
- **Legacy graded (pre-Sept 2024):** overall grade badge
(Outstanding/Good/Requires improvement/Inadequate). Subgrades,
where published, are rendered in the **same area-by-rating chip
list UX as report cards** (one row per judgement area, rating as
a chip) — one visual grammar for inspection detail across both
regimes. Where our dataset has no subgrades for an inspection,
say so honestly ("We don't hold area-by-area detail for this
inspection") and point to the school's Ofsted page — never claim
the inspection itself published no detail (graded inspections
always have subgrades; if it was ungraded, the grade is
carried forward and must be labelled as such).
- **Interim ungraded (Sept 2024 Nov 2025):** parsed outcome
("remains Good") shown as the effective grade, marked as such.
- **Renewed framework report card (from Nov 2025):** no overall
grade exists. Render the report card as an area-by-rating list
using Ofsted's 5-point scale (Exceptional / Strong standard /
Expected standard / Attention needed / Urgent improvement) across
the evaluation areas we model (`rc_inclusion`,
`rc_curriculum_teaching`, `rc_achievement`,
`rc_attendance_behaviour`, `rc_personal_development`,
`rc_leadership_governance`, `rc_early_years`, `rc_sixth_form`)
plus the separate safeguarding met/not-met flag. **At-a-glance
summary rule:** never an unlabelled colour strip — summarise by
counting areas per rating, best first ("5 areas Strong standard ·
3 areas Expected standard"), and always name any area rated
Attention needed or Urgent improvement explicitly (never fold
problems into a count), plus "Safeguarding not met" whenever that
flag is false. When everything is Expected standard or better,
add the reassurance line "No areas need attention".
When a comparison mixes regimes, show a one-line comparability note
("Ofsted changed how it reports in Nov 2025 — a report card and an
older overall grade aren't directly comparable"). Never derive a
fake overall grade from report-card areas.
3. **Academics (KS2)** — one dot-strip row per headline measure (RWM
expected, RWM higher, reading/writing/maths expected), each with the
England-average tick and per-school dots; copy must say "tests and
teacher assessments" (writing is TA, not a test). Progress scores
translated to Above/Average/Below chips (CI-based) — **but note KS2
progress measures ended with 2022/23** (no KS1 baseline afterwards)
and return only when the reception-baseline cohort reaches Y6
(~2027/28), so progress chips apply to historical years in the
trends explorer, not the headline view. Sparkline per school over
the full published period, with an honest gap for the cancelled
test years (2019/202020/21). Disadvantaged-pupils row under an
"Equity" subheading, always with cohort size shown and DfE's full
definition (FSM6 **or** looked-after/previously looked-after).
4. **Getting a place** — oversubscription ratio as plain sentence
("184 applications for 80 places"), first-preference success %, trend
vs last year, admissions policy.
5. **Who goes there** — pupils on roll (vs capacity), boys/girls, FSM %,
EAL %, SEN support %, faith, ages, nursery, trust. *Post-v1:* IDACI
decile in words (needs a coverage check of `fact_deprivation` and
the neighbourhood-not-school caveat, §8.7).
6. **Attendance***post-v1.* The KS2 test-day absence fields are the
only per-school absence data we hold; they're near-zero for most
schools and easy to misread as general attendance. Ship only if a
general-absence source lands.
7. **Explore trends** (existing feature, collapsed) — metric picker +
multi-year line chart + table, with an added England-average
reference line and a COVID-gap annotation.
**Metric exposure model (three tiers).** No measure from the current
page is lost; they surface at three levels of prominence:
- **Tier 1 — headline strips (always visible):** RWM expected,
reading/writing/maths expected, RWM higher standard.
- **Tier 2 — "More measures" expansion inside Academics:** GPS and
science expected % (science labelled teacher-assessed), average
scaled scores (reading/maths/GPS, same dot-strip grammar showing
the 100120 window of the 80120 scale, widening below 100, with
the England tick) — one tap/click away, same visual language.
*Post-v1:* gender split and absence (see §4.6).
- **Tier 3 — Explore trends:** the full grouped catalogue (the
current page's ~40 metrics, including equity and school-context
measures, and the GCSE set for secondary phase) drives the
year-by-year chart and table via the grouped metric picker.
The tier assignment is a content decision per phase (secondary:
Attainment 8, Progress 8 banding, grade 5+ English & maths as tier 1;
EBacc and subject entries as tier 2).
Finance (per-pupil spend) is deliberately deferred: low parent value,
risk of misreading. Revisit later.
**Mobile (design target — mobile first):** the desktop grid is the
adaptation, not the other way round. On mobile the layout goes
*measure-first*: each row is one measure with all schools listed under
it (colour dot + short name + value + chip), so comparison never
requires horizontal swiping between school cards. A sticky horizontal
school-chip bar keeps identity and add/remove available while
scrolling. Dot strips already read measure-first and carry over
unchanged. The trend chart scrolls horizontally inside its container.
## 5. Data strategy — existing dataset only
Constraint (agreed 2026-07-11): use only data already in marts plus
fields already present in the `raw` schema extracts we pull today.
No new external sources.
**Gaps in the mockup, resolved within this constraint:**
| Mockup element | Resolution |
|---|---|
| England average for disadvantaged pupils | Compute from our own data: `stg_ees_ks2` already pivots the Disadvantaged breakdown per school; aggregate it (weighted by eligible pupils) into `fact_ks2_national_averages` or compute in the API. Label it "England average (state schools)". |
| England context for FSM / EAL / SEN chips | Compute dataset-wide medians per phase, same pattern as `/api/national-averages` does for KS4. |
| "Much larger than average" size label | Dataset median pupils-on-roll per phase. |
| Ofsted link | We don't have deep links to the latest report, so always link to the school's Ofsted provider page, `https://reports.ofsted.gov.uk/provider/21/{urn}`, derived from URN (label it "the school's Ofsted page", not "the report"). |
**Raw fields we already pull but don't store — promote to marts (one
dbt/pipeline PR, no tap changes):**
- `raw.ees_admissions`: 2nd/3rd preference applications and offers,
total-preference counts, cross-LA applications and offers → richer
"Getting a place" (e.g. "offers reached 2nd-choice families",
competition from outside the borough).
- `raw.ees_ks2_attainment`: progress-measure confidence intervals and
"working towards" % → lets the Above/Average/Below progress chips be
statistically honest (band by CI overlap with 0, mirroring DfE
methodology) instead of thresholding the point estimate.
- `raw.ees_ks4_performance` / `ees_ks4_info`: `progress8_banding`
(DfE's own plain-English "well above average … well below average"
label — exactly the chip we want for secondary), EBacc entry/APS,
grade-5+ English & maths, `attainment8_diffn`/`progress8_diffn`
(disadvantage gaps) → the secondary-phase version of the Academics
section.
- `raw.ees_census`: young-carer % and the ethnicity breakdown →
optional "Who goes there" enrichment; hold for a later iteration
(presentation needs care), but the data requires no new extract.
- `raw.ofsted_inspections` / tap-uk-ofsted: the `rc_*` report-card
columns exist in staging/marts but are stubbed `null` — the tap has a
TODO to map the report-card column names from the Ofsted MI file
(same monthly extract we already download; inspections from Nov 2025
onward carry them). This is the one promotion that needs a small tap
schema addition, and it's a prerequisite for the new-framework Ofsted
display above.
Explicitly out (not in any current extract): school-level phonics,
workforce/teacher data, per-school attendance beyond the KS2 test-day
absence fields, Ofsted report-card documents themselves.
## 6. API changes
Extend `GET /api/compare` response per URN with the same supplementary
blocks the detail endpoint already builds (`get_supplementary_data`):
`ofsted`, `census`, `admissions` (+ `admissions_history`), `deprivation`,
plus a top-level `national_averages` block for the latest year. Reuse the
existing function; no new tables. Response stays backward-compatible
(additive fields only). Add derived helper fields server-side or compute
chips client-side from `national_averages` (client-side preferred — no
schema churn).
## 7. Accessibility & comprehension devices
- Verdict chips are text + colour + position (never colour alone).
- Every acronym has a tooltip using existing `MetricTooltip`.
- "How to read this" one-liner at the top of each section.
- Chart palette: coral `#e07256`, teal `#00949b`, purple `#8664c9`
(validated: lightness band, chroma, CVD separation, contrast — the
current `--chart-2/-4` tokens fail chroma/contrast checks and should
be nudged to these).
- Numbers rounded to whole percents; England tick labelled on first use.
## 8. Expert-review requirements
An adversarial review by an education-data expert (full findings in
`2026-07-11-compare-screen-expert-review.md`) was applied to the
mockups on 2026-07-12. The following are binding requirements for
implementation, beyond what the mockups can show:
1. **Chart truthfulness:** KS2 tests were cancelled 2019/202020/21
only. **2021/22 school-level figures are a permanent source gap**
DfE stated it would not publish KS2 2021/22 in performance tables
(verified 2026-07-12 against EES, the CSP download service, and
DfE release notes; see `# TASK 6 VERIFICATION` in
`pipeline/scripts/diagnose_compare_gaps.py`). The chart's England-
only 2021/22 point with broken school lines is therefore the
correct permanent rendering; copy should say "DfE didn't publish
school-level figures for 2021/22", not "not in our dataset yet".
The 2015/16 national figure and the GPS/science/scaled-score
England averages ARE loadable (mapping already correct; refreshed
raw extract backfills them). Never render missing years as if time
were continuous.
2. **Report-card summaries** count graded areas only — safeguarding is
a separate binary flag, never included in rating counts.
3. **Admissions:** use the real preference-breakdown fields from
`raw.ees_admissions`; never derive "lower-preference offers" as
places first-preference offers. Frame total applications as
"named on N forms" (any rank), lead with first-preference success,
and standardise at-a-glance chips on that one metric. Explain the
equal-preference rule; caveat offers vs final intake (waiting
lists/appeals); condition "distance decides" on the school's actual
oversubscription criteria where we have the admissions-policy field.
4. **Ofsted:** overall grades ended September 2024 (report cards from
November 2025); the interim regime must be renderable. Distinguish
graded (s5) vs ungraded (s8) inspections and surface carried-forward
grades as such; "we don't hold the detail" is a statement about our
dataset, never about the inspection. Verify every scale/area label
against Ofsted's final published toolkit before launch (e.g. "Needs
attention" vs "Attention needed"; "Personal development and
well-being" vs the identically-named legacy judgement). Check
whether a school's latest inspection is merely its latest *graded*
one. Confirm Early years provision subgrades flow through the
pipeline for schools with nurseries.
5. **Subgroup honesty:** disadvantaged-pupil percentages carry cohort
sizes and follow the DfE suppression rule (≤5 pupils suppressed);
state the rule verbatim in the footer.
6. **Benchmark provenance:** official DfE figures and
dataset-computed benchmarks must be labelled distinctly and
consistently everywhere (a computed median is a "benchmark",
not an "England average").
7. **Copy details:** "Latest Ofsted inspection" (not "Ofsted rating");
"EHC plans"; SEN-support benchmark ≈14%; high SEN share may
indicate resourced provision (say so neutrally); "at or above
capacity" rather than "full" (capacity data is often stale);
disambiguate Ofsted's "Expected standard" from the KS2 measure;
give official terms ("first preference") alongside plain English.
Writing has no "higher standard" — its TA outcome is "greater
depth (GDS)"; never list writing under a higher-standard group.
Science and writing are teacher-assessed and must be labelled as
such (no KS2 science test since 2009). House style for benchmark
provenance: official DfE figures say "England average"; computed
figures say "state-school average (computed from our dataset)" —
applied to every chip, tooltip, header note and section intro.
EAL uses the census wording: first language known or believed to
be other than English. If IDACI ships, caveat that it describes
pupils' home neighbourhoods, not the school.
## 9. Rollout
1. **PR 1 (backend):** extend `/api/compare` + tests.
2. **PR 2 (frontend):** new compare layout behind the existing route;
e2e journey updated in the same PR (promotion gate).
3. **Fix separately:** missing third series on the current chart.
## 10. Open questions for review
- Max schools: keep 10 in API but cap visible columns at 4 with scroll?
- Should distance-from-home appear when the user searched by postcode
(data exists via `dim_location`)?
- Keep finance out of v1? (Recommended: yes, out.)
@@ -0,0 +1,642 @@
<title>Compare screen — proposed redesign</title>
<style>
:root {
--bg: #faf7f2;
--bg-2: #f3ede4;
--card: #ffffff;
--ink: #1a1612;
--ink-2: #5c564d;
--ink-3: #6d685f;
--border: #e5dfd5;
--accent: #b04a2e;
--accent-bg: rgba(224, 114, 86, 0.12);
/* Validated series palette (passes lightness / chroma / CVD / contrast) */
--s1: #e07256; --s1-text: #b04a2e;
--s2: #00949b; --s2-text: #006a70;
--s3: #8664c9; --s3-text: #6a4bab;
--eng: #6d685f;
--good-bg: #e3efe6; --good-text: #1a6b34;
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
--bad-bg: #f7e3de; --bad-text: #a03a20;
--neutral-bg: #efeae1; --neutral-text: #5c564d;
--shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
--display: 'Playfair Display', Georgia, 'Times New Roman', serif;
--body: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
--s1: #f08b6e; --s1-text: #f4a58e;
--s2: #2fb8ae; --s2-text: #5fd0c8;
--s3: #a98fe0; --s3-text: #c0abec;
--eng: #9a927f;
--good-bg: #1e3325; --good-text: #7fd39a;
--warn-bg: #38301a; --warn-text: #e4c268;
--bad-bg: #3d221b; --bad-text: #f0977f;
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
--shadow: 0 2px 8px rgba(0,0,0,0.35);
}
}
:root[data-theme="dark"] {
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
--s1: #f08b6e; --s1-text: #f4a58e;
--s2: #2fb8ae; --s2-text: #5fd0c8;
--s3: #a98fe0; --s3-text: #c0abec;
--eng: #9a927f;
--good-bg: #1e3325; --good-text: #7fd39a;
--warn-bg: #38301a; --warn-text: #e4c268;
--bad-bg: #3d221b; --bad-text: #f0977f;
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
--shadow: 0 2px 8px rgba(0,0,0,0.35);
}
:root[data-theme="light"] {
--bg: #faf7f2; --bg-2: #f3ede4; --card: #ffffff;
--ink: #1a1612; --ink-2: #5c564d; --ink-3: #6d685f;
--border: #e5dfd5; --accent: #b04a2e; --accent-bg: rgba(224,114,86,0.12);
--s1: #e07256; --s1-text: #b04a2e;
--s2: #00949b; --s2-text: #006a70;
--s3: #8664c9; --s3-text: #6a4bab;
--eng: #6d685f;
--good-bg: #e3efe6; --good-text: #1a6b34;
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
--bad-bg: #f7e3de; --bad-text: #a03a20;
--neutral-bg: #efeae1; --neutral-text: #5c564d;
--shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
}
body { background: var(--bg); color: var(--ink); font-family: var(--body); line-height: 1.5; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1.25rem 5rem; }
.mock-note {
background: var(--accent-bg); border: 1px solid var(--border); border-radius: 8px;
padding: 0.6rem 1rem; font-size: 0.85rem; color: var(--ink-2); margin-bottom: 2rem;
}
.mock-note strong { color: var(--accent); }
h1 { font-family: var(--display); font-size: 2.4rem; font-weight: 700; margin: 0 0 0.25rem; text-wrap: balance; }
.sub { color: var(--ink-2); margin: 0 0 2rem; max-width: 60ch; }
h2.section-title {
font-family: var(--display); font-size: 1.45rem; font-weight: 700;
margin: 0; padding-left: 0.75rem; border-left: 3px solid var(--accent);
}
.how { font-size: 0.85rem; color: var(--ink-3); margin: 0.35rem 0 0 0.95rem; max-width: 70ch; }
section.block { margin-top: 3rem; }
/* ---- Sticky school header ---- */
.school-bar {
position: sticky; top: 0; z-index: 10;
background: var(--bg); padding: 0.75rem 0; border-bottom: 1px solid var(--border);
display: grid; grid-template-columns: repeat(3, 1fr) auto; gap: 0.75rem; align-items: stretch;
}
.school-chip {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
box-shadow: var(--shadow); padding: 0.55rem 0.75rem; display: flex; gap: 0.55rem; align-items: center;
border-top: 3px solid var(--chip);
}
.school-chip .dot { width: 11px; height: 11px; border-radius: 50%; background: var(--chip); flex: none; }
.school-chip .nm { font-weight: 600; font-size: 0.92rem; line-height: 1.25; }
.school-chip .la { font-size: 0.78rem; color: var(--ink-3); }
.school-chip .x {
margin-left: auto; border: none; background: var(--bg-2); color: var(--ink-3);
border-radius: 50%; width: 22px; height: 22px; cursor: pointer; flex: none; font-size: 0.9rem;
}
.add-school {
border: 1.5px dashed var(--border); border-radius: 8px; background: none;
color: var(--accent); font-weight: 600; padding: 0 1rem; cursor: pointer; font-family: inherit; font-size: 0.9rem;
}
/* ---- Comparison grid rows ---- */
.grid { display: grid; grid-template-columns: 200px repeat(3, 1fr); gap: 0 0.75rem; margin-top: 1.25rem; }
.grid .rowlabel {
font-size: 0.85rem; font-weight: 600; color: var(--ink-2); padding: 0.85rem 0.5rem 0.85rem 0;
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.35rem;
}
.grid .cell { padding: 0.85rem 0.25rem; border-bottom: 1px solid var(--border); font-size: 0.95rem; }
.grid .cell .big { font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.grid .cell .small { display: block; font-size: 0.8rem; color: var(--ink-3); margin-top: 0.1rem; }
.help {
display: inline-flex; width: 15px; height: 15px; border-radius: 50%;
border: 1px solid var(--ink-3); color: var(--ink-3); font-size: 0.65rem;
align-items: center; justify-content: center; cursor: help; flex: none;
}
.chip {
display: inline-block; font-size: 0.75rem; font-weight: 600; border-radius: 999px;
padding: 0.15rem 0.6rem; white-space: nowrap;
}
.chip.good { background: var(--good-bg); color: var(--good-text); }
.chip.warn { background: var(--warn-bg); color: var(--warn-text); }
.chip.bad { background: var(--bad-bg); color: var(--bad-text); }
.chip.neutral { background: var(--neutral-bg); color: var(--neutral-text); }
.ofsted-badge {
display: inline-block; font-weight: 700; border-radius: 6px; padding: 0.25rem 0.7rem; font-size: 0.9rem;
}
/* New-framework report card */
.rc-list { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.2rem; }
.rc-row { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
.rc-row .a { color: var(--ink-2); }
.illus {
display: inline-block; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.03em;
color: var(--accent); border: 1px dashed var(--accent); border-radius: 4px; padding: 0.05rem 0.4rem;
}
.ofsted-1 { background: var(--good-bg); color: var(--good-text); }
.ofsted-2 { background: #e7f0e3; color: #3c6b2f; }
:root[data-theme="dark"] .ofsted-2 { background:#25321f; color:#a5cf94; }
@media (prefers-color-scheme: dark) { .ofsted-2 { background:#25321f; color:#a5cf94; } }
/* ---- Dot strips (signature element) ---- */
.strip-row { margin: 1.1rem 0 1.6rem; }
.strip-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; flex-wrap: wrap; }
.strip-head .t { font-weight: 600; font-size: 0.95rem; }
.strip-head .eng-note { font-size: 0.8rem; color: var(--ink-3); }
.strip { position: relative; height: 34px; margin-top: 0.45rem; }
.strip .track { position: absolute; left: 0; right: 0; top: 15px; height: 4px; border-radius: 2px; background: var(--bg-2); }
.strip .eng-tick { position: absolute; top: 4px; width: 2px; height: 26px; background: var(--eng); }
.strip .eng-lbl { position: absolute; top: -14px; transform: translateX(-50%); font-size: 0.7rem; color: var(--ink-3); white-space: nowrap; }
.strip .pt {
position: absolute; top: 9px; width: 16px; height: 16px; border-radius: 50%;
transform: translateX(-50%); border: 2px solid var(--card); box-shadow: 0 0 0 1px rgba(0,0,0,0.08);
}
.strip .pt-lbl { position: absolute; top: 27px; transform: translateX(-50%); font-size: 0.72rem; font-weight: 600; font-variant-numeric: tabular-nums; }
details.more-measures { margin-top: 0.5rem; border-top: 1px solid var(--border); padding-top: 0.75rem; }
details.more-measures summary { cursor: pointer; font-weight: 600; font-size: 0.88rem; color: var(--accent); }
.strip-note { font-size: 0.78rem; color: var(--ink-3); margin: 0.5rem 0 0; }
.metric-picker { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; flex-wrap: wrap; }
.metric-picker label { font-size: 0.85rem; font-weight: 600; color: var(--ink-2); }
.metric-picker select {
font-family: inherit; font-size: 0.9rem; padding: 0.4rem 0.6rem; border-radius: 8px;
border: 1px solid var(--border); background: var(--card); color: var(--ink); max-width: 100%;
}
.picker-note { font-size: 0.78rem; color: var(--ink-3); }
.legend { display: flex; gap: 1.1rem; flex-wrap: wrap; font-size: 0.82rem; color: var(--ink-2); margin: 0.75rem 0 0; }
.legend .li { display: inline-flex; align-items: center; gap: 0.4rem; }
.legend .sw { width: 10px; height: 10px; border-radius: 50%; }
.legend .engsw { width: 2px; height: 12px; background: var(--eng); }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
box-shadow: var(--shadow); padding: 1.25rem 1.5rem; margin-top: 1rem;
}
/* ---- Sparklines & chart ---- */
.spark { display: block; }
.chart-wrap { overflow-x: auto; }
.covid { fill: var(--bg-2); }
.covid-lbl { font-size: 0.7rem; fill: var(--ink-3); }
.barmini { height: 8px; border-radius: 4px; background: var(--bg-2); overflow: hidden; margin-top: 0.3rem; max-width: 140px; }
.barmini > i { display: block; height: 100%; border-radius: 4px; }
details.explore { margin-top: 1rem; }
details.explore summary {
cursor: pointer; font-weight: 600; color: var(--accent);
padding: 0.85rem 1.1rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px;
}
details.explore[open] summary { border-radius: 8px 8px 0 0; }
details.explore .inner { border: 1px solid var(--border); border-top: none; border-radius: 0 0 8px 8px; background: var(--card); padding: 1.25rem 1.5rem; }
.footnote { font-size: 0.78rem; color: var(--ink-3); margin-top: 2.5rem; border-top: 1px solid var(--border); padding-top: 1rem; max-width: 75ch; }
@media (max-width: 760px) {
.school-bar { grid-template-columns: 1fr; }
.grid { grid-template-columns: 1fr; }
.grid .rowlabel { border-bottom: none; padding-bottom: 0.15rem; background: var(--bg-2); border-radius: 6px; padding: 0.4rem 0.6rem; margin-top: 0.8rem; }
.grid .cell { border-bottom: none; padding: 0.4rem 0.6rem; }
.grid .cell::before { content: attr(data-school); display: block; font-size: 0.72rem; font-weight: 600; color: var(--sc, var(--ink-3)); }
}
</style>
<div class="wrap">
<p class="mock-note"><strong>Mockup — proposed redesign of /compare.</strong> All figures are live production data for three real schools (2024/25 results, 2026/27 admissions round). England averages for test results are official DfE figures; other benchmarks are state-school averages computed from our dataset.</p>
<h1>Compare schools</h1>
<p class="sub">Three schools side by side — inspection results, academics, admissions and community, each anchored against the England average so you can tell at a glance what's typical and what stands out.</p>
<div class="school-bar" aria-label="Schools in this comparison">
<div class="school-chip" style="--chip: var(--s1)">
<span class="dot"></span>
<span><span class="nm">Barclay Primary School</span><br><span class="la">Waltham Forest · Academy</span></span>
<button class="x" aria-label="Remove Barclay Primary School">×</button>
</div>
<div class="school-chip" style="--chip: var(--s2)">
<span class="dot"></span>
<span><span class="nm">Elmhurst Primary School</span><br><span class="la">Newham · Academy</span></span>
<button class="x" aria-label="Remove Elmhurst Primary School">×</button>
</div>
<div class="school-chip" style="--chip: var(--s3)">
<span class="dot"></span>
<span><span class="nm">Plumcroft Primary School</span><br><span class="la">Greenwich · Community school</span></span>
<button class="x" aria-label="Remove Plumcroft Primary School">×</button>
</div>
<button class="add-school">+ Add school</button>
</div>
<!-- ============ AT A GLANCE ============ -->
<section class="block" style="margin-top:2rem">
<h2 class="section-title">At a glance</h2>
<p class="how">The short version — each row below is explained in its own section further down.</p>
<div class="grid">
<div class="rowlabel">Latest Ofsted inspection</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Inspected Oct 2021</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Inspected Oct 2021</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">
<strong style="font-size:0.9rem">Report card</strong> <span class="illus">illustrative</span>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;margin-top:0.3rem">
<span class="chip good">4 areas Strong standard</span>
<span class="chip neutral">2 areas Expected standard</span>
<span class="chip warn">Attendance &amp; behaviour: Attention needed</span>
</div>
<span class="small">Safeguarding met · Nov 2025</span>
</div>
<div class="rowlabel">Children reaching the expected standard <span class="help" title="% of Year 6 pupils reaching the expected standard in reading, writing and maths (2024/25). England average: 62%.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="big" style="color:var(--s1-text)">87%</span> <span class="chip good">Above England average</span><span class="small">England average 62%</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="big" style="color:var(--s2-text)">92%</span> <span class="chip good">Above England average</span><span class="small">England average 62%</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="big" style="color:var(--s3-text)">79%</span> <span class="chip good">Above England average</span><span class="small">England average 62%</span></div>
<div class="rowlabel">Getting a place</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="chip good">97% of first choices offered</span><span class="small">Named on 457 forms · 180 places</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="chip warn">73% of first choices offered</span><span class="small">Named on 342 forms · 120 places</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="chip good">All first choices offered</span><span class="small">Named on 185 forms · 80 places</span></div>
<div class="rowlabel">Size</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">1,273 pupils<span class="small">Much larger than average</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">980 pupils<span class="small">Much larger than average</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">1,056 pupils<span class="small">Much larger than average</span></div>
</div>
</section>
<!-- ============ OFSTED ============ -->
<section class="block">
<h2 class="section-title">Ofsted inspection</h2>
<p class="how">Ofsted is the schools inspectorate. It stopped giving a single overall grade in <strong>September 2024</strong>; inspections between then and November 2025 kept the area-by-area judgements without an overall grade, and from <strong>November 2025</strong> new inspections produce a <strong>report card</strong> rating each area of school life on a five-point scale (Exceptional · Strong standard · Expected standard · Attention needed · Urgent improvement). A report card and an older overall grade aren't directly comparable — Plumcroft's report card below is an <em>illustrative example</em> of the new format, as no school in our dataset has one yet. (Ofsted's "Expected standard" rating is unrelated to the KS2 "expected standard" test measure further down this page.)</p>
<div class="grid">
<div class="rowlabel">Result</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Overall grade (older-style inspection)</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="ofsted-badge ofsted-1">Outstanding</span><span class="small">Overall grade (older-style inspection)</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><strong>Report card</strong> <span class="illus">illustrative</span><span class="small">New-style inspection — no overall grade is given</span></div>
<div class="rowlabel">Inspected</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">7 Oct 2021 <span class="chip neutral">4+ years ago</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">6 Oct 2021 <span class="chip neutral">4+ years ago</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">14 Nov 2025</div>
<div class="rowlabel">Judgement detail <span class="help" title="Older-style inspections: one rating per judgement area, where published. New-style inspections: the full report card, one rating per area of school life.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="small">We don't hold area-by-area detail for this inspection — see Barclay's Ofsted page for the full report.</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">
<div class="rc-list">
<div class="rc-row"><span class="a">Quality of education</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Behaviour &amp; attitudes</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Leadership &amp; management</span><span class="chip good">Outstanding</span></div>
</div>
</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">
<div class="rc-list">
<div class="rc-row"><span class="a">Achievement</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Curriculum &amp; teaching</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Attendance &amp; behaviour</span><span class="chip warn">Attention needed</span></div>
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Inclusion</span><span class="chip neutral">Expected standard</span></div>
<div class="rc-row"><span class="a">Leadership &amp; governance</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Early years</span><span class="chip neutral">Expected standard</span></div>
<div class="rc-row"><span class="a">Safeguarding</span><span class="chip good">Met</span></div>
</div>
</div>
<div class="rowlabel">Ofsted page <span class="help" title="Links to the school's page on ofsted.gov.uk, where all its inspection reports are listed.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><a href="https://reports.ofsted.gov.uk/provider/21/138690" style="color:var(--accent)">Barclay's Ofsted page →</a></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><a href="https://reports.ofsted.gov.uk/provider/21/145362" style="color:var(--accent)">Elmhurst's Ofsted page →</a></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><a href="https://reports.ofsted.gov.uk/provider/21/100140" style="color:var(--accent)">Plumcroft's Ofsted page →</a></div>
</div>
</section>
<!-- ============ ACADEMICS ============ -->
<section class="block">
<h2 class="section-title">How children do academically</h2>
<p class="how">Results from national tests and teacher assessments at the end of Year 6 (2024/25) — writing is assessed by teachers, not tested. Each line runs from 0100%; the grey tick marks the England average, so dots to its right are above average.</p>
<div class="card">
<div id="strips"></div>
<details class="more-measures">
<summary>More measures — grammar, punctuation &amp; spelling, science, average scaled scores</summary>
<div id="strips-more"></div>
<p class="strip-note">The strips show the 100120 window of the full 80120 scaled-score range; 100 is the expected standard, and the strip widens if a school averages below it. England ticks for grammar, punctuation &amp; spelling and science aren't in our dataset yet, and the scaled-score England ticks are indicative — official DfE figures for all of these will be loaded before launch.</p>
</details>
<div class="legend" id="strip-legend"></div>
</div>
<div class="grid">
<div class="rowlabel">Trend, 2015/16 to 2024/25 <span class="help" title="% reaching the expected standard in reading, writing and maths each year. Tests were cancelled in 2019/20 and 2020/21 (COVID); 2021/22 school figures aren't in our dataset yet — the line breaks over those years.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><svg class="spark" data-series="52,76,65,87,87,75,87" data-color="s1" width="150" height="40" role="img" aria-label="Barclay trend: variable, most recently 87%"></svg><span class="small">Variable, recently 87%</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><svg class="spark" data-series="80,81,86,88,88,88,92" data-color="s2" width="150" height="40" role="img" aria-label="Elmhurst trend: 80% rising to 92%"></svg><span class="small">Consistently high</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><svg class="spark" data-series="58,64,57,69,62,72,79" data-color="s3" width="150" height="40" role="img" aria-label="Plumcroft trend: 58% rising to 79%"></svg><span class="small">Improving since 2022/23</span></div>
<div class="rowlabel">Children from lower-income families <span class="help" title="% of disadvantaged pupils (eligible for free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. State-school average: 46% (computed from our dataset). Based on smaller pupil groups, so a single pupil can move a school's figure noticeably.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="big" style="font-size:1.1rem">86%</span> <span class="chip good">Well above the 46% state-school average</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="big" style="font-size:1.1rem">93%</span> <span class="chip good">Well above the 46% state-school average</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="big" style="font-size:1.1rem">72%</span> <span class="chip good">Above the 46% state-school average</span></div>
</div>
</section>
<!-- ============ ADMISSIONS ============ -->
<section class="block">
<h2 class="section-title">Getting a place</h2>
<p class="how">From the most recent admissions round (September 2026 entry). "First choice" means families who ranked the school top of their application form — officially a "first preference". Schools never see your ranking: places are decided only by the school's admission criteria, so listing a school lower down never hurts your chances. These are National Offer Day offers — waiting lists and appeals can change the final intake.</p>
<div class="grid">
<div class="rowlabel">Interest in the school <span class="help" title="How many application forms named the school at any preference rank — not the number of families competing head-to-head for a place.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">Named on <span class="big" style="font-size:1.1rem">457</span> forms · <strong>180</strong> places</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">Named on <span class="big" style="font-size:1.1rem">342</span> forms · <strong>120</strong> places</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">Named on <span class="big" style="font-size:1.1rem">185</span> forms · <strong>80</strong> places</div>
<div class="rowlabel">First-choice families offered a place</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><strong>97%</strong><span class="barmini"><i style="width:97%;background:var(--s1)"></i></span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><strong>73%</strong> <span class="chip warn">Over 1 in 4 first choices missed out</span><span class="barmini"><i style="width:73%;background:var(--s2)"></i></span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><strong>100%</strong><span class="barmini"><i style="width:100%;background:var(--s3)"></i></span></div>
<div class="rowlabel">What this means</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)"><span class="small">Nearly every family who put Barclay first got a place.</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)"><span class="small">More first-choice applications than places — check the school's admission criteria (for most non-faith primaries, distance decides).</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)"><span class="small">Every family who put Plumcroft first got a place.</span></div>
</div>
</section>
<!-- ============ COMMUNITY ============ -->
<section class="block">
<h2 class="section-title">Who goes there</h2>
<p class="how">The school's community, from the latest school census (2025/26). England averages are shown for context — there's no "right" number here.</p>
<div class="grid">
<div class="rowlabel">Pupils on roll</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">1,273 <span class="small">1,260 places — at or above capacity</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">980 <span class="small">of 996 places (98% full)</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">1,056 <span class="small">1,050 places — at or above capacity</span></div>
<div class="rowlabel">Girls / boys</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">51% / 49%</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">48% / 52%</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">51% / 49%</div>
<div class="rowlabel">Free school meals <span class="help" title="% of pupils eligible for free school meals — a common measure of how many pupils come from lower-income families. State-school average: 25% (computed from our dataset).">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">26% <span class="chip neutral">About the state-school average</span></div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">25% <span class="chip neutral">About the state-school average</span></div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">30% <span class="chip neutral">A little above average</span></div>
<div class="rowlabel">English as an additional language <span class="help" title="% of pupils whose first language is known or believed to be other than English. State-school average: 22% (computed from our dataset).">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">62%</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">84%</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">20%</div>
<div class="rowlabel">Extra learning support (SEN) <span class="help" title="% of pupils receiving SEN support (not including EHC plans). State-school average: 14% (computed from our dataset). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign.">?</span></div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">6%</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">8%</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">28% <span class="chip neutral">Well above average</span></div>
<div class="rowlabel">Faith character</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">None</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">None</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">None</div>
<div class="rowlabel">Ages · nursery</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">311 · has a nursery</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">311 · has a nursery</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">311 · has a nursery</div>
<div class="rowlabel">Run by</div>
<div class="cell" data-school="Barclay" style="--sc:var(--s1-text)">Lion Academy Trust</div>
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">New Vision Trust</div>
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">Greenwich council</div>
</div>
</section>
<!-- ============ EXPLORE TRENDS ============ -->
<section class="block">
<h2 class="section-title">Explore trends</h2>
<p class="how">The full year-by-year explorer — every measure from the current compare page lives on here, grouped, each with its England-average line. Three measures are wired up in this mockup; the rest are shown to convey the catalogue.</p>
<details class="explore" open>
<summary>Year-by-year trends, 2015/16 to 2024/25</summary>
<div class="inner chart-wrap">
<div class="metric-picker">
<label for="metric-select">Measure:</label>
<select id="metric-select">
<optgroup label="Expected standard">
<option value="rwm" selected>Reading, writing &amp; maths (combined)</option>
<option value="reading">Reading</option>
<option value="maths">Maths</option>
<option disabled>Writing (teacher assessment)</option>
<option disabled>Grammar, punctuation &amp; spelling</option>
<option disabled>Science</option>
</optgroup>
<optgroup label="Higher standard">
<option disabled>Reading, writing &amp; maths (combined)</option>
<option disabled>Reading · Maths · GPS</option>
<option disabled>Writing — greater depth (teacher assessment)</option>
</optgroup>
<optgroup label="Average scaled scores">
<option disabled>Reading · Maths · GPS</option>
</optgroup>
<optgroup label="Equity">
<option disabled>Disadvantaged pupils — expected standard</option>
</optgroup>
<optgroup label="School context">
<option disabled>Free school meals % · EAL % · SEN support %</option>
</optgroup>
</select>
<span class="picker-note">School lines break where a year isn't in our dataset.</span>
</div>
<svg id="trendchart" width="880" height="300" role="img" aria-label="Line chart: selected measure by year for three schools and England average"></svg>
<div class="legend" id="trend-legend"></div>
</div>
</details>
</section>
<p class="footnote">
Sources: DfE Compare School Performance (KS2 results), Ofsted inspection outcomes, DfE school admissions data, school census — all from datasets SchoolCompare already collects. England averages for test results are the official DfE national figures; benchmarks for free school meals, language, SEN, school size and disadvantaged pupils' results are computed across all state schools in our dataset. Following DfE practice, figures based on 5 or fewer pupils are suppressed and shown as "no data". This is a static mockup: tooltips and "Add school" are illustrative, and Plumcroft's Ofsted report card is a made-up example of the November 2025 format (its real latest inspection is Good, June 2023) — no school in our dataset has a report card yet.
</p>
</div>
<script>
(function () {
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const schools = [
{ name: 'Barclay', color: () => css('--s1'), text: () => css('--s1-text') },
{ name: 'Elmhurst', color: () => css('--s2'), text: () => css('--s2-text') },
{ name: 'Plumcroft', color: () => css('--s3'), text: () => css('--s3-text') },
];
/* ---- Dot strips ---- */
const strips = [
{ label: 'Reading, writing & maths — expected standard', eng: 62, vals: [87, 92, 79] },
{ label: 'Reading', eng: 75, vals: [91, 92, 87] },
{ label: 'Writing', eng: 72, vals: [94, 92, 83] },
{ label: 'Maths', eng: 74, vals: [91, 95, 84] },
{ label: 'Working at a higher standard than expected', eng: 8, vals: [22, 27, 12], tip: 'A high score in the reading and maths tests plus \u201cgreater depth\u201d in teacher-assessed writing.' },
];
// Generalised strip renderer: any domain (percentages or scaled scores),
// England tick optional (omitted when the benchmark isn't in our dataset).
function renderStrips(list, el, firstNote) {
list.forEach((s, si) => {
const min = s.min ?? 0, max = s.max ?? 100, unit = s.unit ?? '%';
const pos = v => (v - min) / (max - min) * 100;
const row = document.createElement('div');
row.className = 'strip-row';
let pts = '';
const sorted = s.vals.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v);
let lastBelow = -Infinity;
const nudge = (max - min) * 0.04;
sorted.forEach(({ v, i }) => {
const above = (v - lastBelow) < nudge;
if (!above) lastBelow = v;
pts += `<span class="pt" style="left:${pos(v)}%;background:${schools[i].color()}" title="${schools[i].name}: ${v}${unit}"></span>`
+ `<span class="pt-lbl" style="left:${pos(v)}%;color:${schools[i].text()};${above ? 'top:-6px' : ''}">${v}</span>`;
});
const eng = s.eng != null
? `<span class="eng-tick" style="left:${pos(s.eng)}%"></span><span class="eng-lbl" style="left:${pos(s.eng)}%">England ${s.eng}${unit}</span>`
: '';
row.innerHTML = `
<div class="strip-head"><span class="t"${s.tip ? ` title="${s.tip}"` : ''}>${s.label}</span>
${si === 0 && firstNote ? `<span class="eng-note">${firstNote}</span>` : ''}</div>
<div class="strip" role="img" aria-label="${s.label}: ${s.eng != null ? 'England average ' + s.eng + unit + ', ' : ''}${s.vals.map((v, i) => schools[i].name + ' ' + v + unit).join(', ')}">
<div class="track"></div>
${eng}
${pts}
</div>`;
el.appendChild(row);
});
}
renderStrips(strips, document.getElementById('strips'), '│ grey tick = England average');
renderStrips([
{ label: 'Grammar, punctuation & spelling — expected standard', eng: null, vals: [86, 94, 82] },
{ label: 'Science — expected standard (teacher-assessed)', eng: null, vals: [95, 91, 83], tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.' },
{ label: 'Average scaled score — reading', eng: 106, vals: [107, 110, 109], min: 100, max: 120, unit: '' },
{ label: 'Average scaled score — maths', eng: 105, vals: [108, 113, 108], min: 100, max: 120, unit: '' },
{ label: 'Average scaled score — grammar, punctuation & spelling', eng: 105, vals: [107, 114, 109], min: 100, max: 120, unit: '' },
], document.getElementById('strips-more'), null);
document.getElementById('strip-legend').innerHTML =
schools.map(s => `<span class="li"><span class="sw" style="background:${s.color()}"></span>${s.name}</span>`).join('')
+ '<span class="li"><span class="engsw"></span>England average</span>';
/* ---- Sparklines (x honours real time: values are 15/1618/19 then 22/2324/25) ---- */
document.querySelectorAll('svg.spark').forEach(svg => {
const vals = svg.dataset.series.split(',').map(Number);
const color = css('--' + svg.dataset.color);
const w = +svg.getAttribute('width'), h = +svg.getAttribute('height');
const min = 40, max = 100;
const sslots = [0,1,2,3,7,8,9];
const x = i => 4 + sslots[i] * (w - 8) / 9;
const y = v => h - 6 - (v - min) * (h - 12) / (max - min);
const seg = idx => idx.map((i, k) => (k ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(vals[i]).toFixed(1)).join(' ');
svg.innerHTML = `<path d="${seg([0,1,2,3])}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`
+ `<path d="${seg([4,5,6])}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`
+ `<circle cx="${x(6)}" cy="${y(vals[6])}" r="3.5" fill="${color}"/>`;
});
/* ---- Trend chart (metric-driven) ----
Tests were cancelled 2019/20-2020/21; lines always break across that band.
Nulls elsewhere are dataset gaps (no school-level 2021/22 rows; no
subject-level 2022/23 rows) and break the lines honestly. England figures
are official DfE (fact_ks2_national_averages); 2015/16 not loaded yet. */
const years = ['2015/16','2016/17','2017/18','2018/19','2021/22','2022/23','2023/24','2024/25'];
const METRICS = {
rwm: {
label: 'Reading, writing & maths - expected standard',
school: { Barclay: [52,76,65,87,null,87,75,87], Elmhurst: [80,81,86,88,null,88,88,92], Plumcroft: [58,64,57,69,null,62,72,79] },
england: [null,61.1,64.3,64.9,58.7,59.5,60.6,62.1],
},
reading: {
label: 'Reading - expected standard',
school: { Barclay: [57,84,76,87,null,null,79,91], Elmhurst: [88,91,92,91,null,null,92,92], Plumcroft: [59,68,72,79,null,null,82,87] },
england: [null,71.6,75.3,73.2,74.6,72.8,74.4,75.0],
},
maths: {
label: 'Maths - expected standard',
school: { Barclay: [81,82,74,90,null,null,88,91], Elmhurst: [96,89,97,97,null,null,96,95], Plumcroft: [80,80,69,90,null,null,84,84] },
england: [null,74.8,75.5,78.7,71.5,73.0,73.2,74.0],
},
};
const schoolColor = { Barclay: () => css('--s1'), Elmhurst: () => css('--s2'), Plumcroft: () => css('--s3') };
const svg = document.getElementById('trendchart');
const W = 880, H = 300, L = 44, R = 48, T = 18, B = 40;
// x positions honour real time: 2019/20 and 2020/21 (cancelled tests) sit between slots 3 and 6
const slots = [0,1,2,3,6,7,8,9]; const maxSlot = 9;
const X = i => L + slots[i] * (W - L - R) / maxSlot;
const Y = v => T + (100 - v) * (H - T - B) / 60; // domain 40..100
// Contiguous non-null runs, additionally split across the covid band (between indices 3 and 4)
function runs(vals) {
const out = []; let cur = [];
vals.forEach((v, i) => {
if (v == null) { if (cur.length) out.push(cur); cur = []; return; }
if (i === 4 && cur.length && cur[cur.length - 1] === 3) { out.push(cur); cur = []; }
cur.push(i);
});
if (cur.length) out.push(cur);
return out;
}
function renderChart(key) {
const m = METRICS[key];
const ink3 = css('--ink-3'), border = css('--border');
let g = '';
const bx1 = X(3) + 14, bx2 = X(4) - 14;
g += `<rect class="covid" x="${bx1}" y="${T}" width="${bx2 - bx1}" height="${H - T - B}" rx="4"/>`;
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 16}" text-anchor="middle">tests cancelled</text>`;
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 30}" text-anchor="middle">2019/20-2020/21</text>`;
for (let v = 40; v <= 100; v += 20) {
g += `<line x1="${L}" y1="${Y(v)}" x2="${W - R}" y2="${Y(v)}" stroke="${border}" stroke-width="1"/>`;
g += `<text x="${L - 8}" y="${Y(v) + 4}" text-anchor="end" font-size="11" fill="${ink3}">${v}%</text>`;
}
years.forEach((yr, i) => {
g += `<text x="${X(i)}" y="${H - B + 20}" text-anchor="middle" font-size="11" fill="${ink3}">${yr}</text>`;
});
const path = (vals, idx) => idx.map((i, k) => (k ? 'L' : 'M') + X(i) + ' ' + Y(vals[i])).join(' ');
runs(m.england).forEach(idx => {
if (idx.length > 1) g += `<path d="${path(m.england, idx)}" fill="none" stroke="${ink3}" stroke-width="1.5" stroke-dasharray="5 4"/>`;
else g += `<circle cx="${X(idx[0])}" cy="${Y(m.england[idx[0]])}" r="2.5" fill="${ink3}"><title>England ${years[idx[0]]}: ${Math.round(m.england[idx[0]])}%</title></circle>`;
});
Object.entries(m.school).forEach(([name, vals]) => {
const color = schoolColor[name]();
runs(vals).forEach(idx => {
if (idx.length > 1) g += `<path d="${path(vals, idx)}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
});
vals.forEach((v, i) => {
if (v == null) return;
g += `<circle cx="${X(i)}" cy="${Y(v)}" r="3.5" fill="${color}" stroke="${css('--card')}" stroke-width="1.5"><title>${name} ${years[i]}: ${v}%</title></circle>`;
});
});
// End labels, pushed apart when schools finish close together
const ends = Object.entries(m.school)
.map(([name, vals]) => ({ v: vals[vals.length - 1], i: vals.length - 1 }))
.filter(e => e.v != null)
.sort((a, b) => a.v - b.v);
let prevY = Infinity;
ends.forEach(e => {
let y = Y(e.v) + 4;
if (prevY - y < 13) y = prevY - 13;
prevY = y;
g += `<text x="${X(e.i) + 8}" y="${y}" font-size="11" font-weight="600" fill="${ink3}">${e.v}%</text>`;
});
svg.innerHTML = g;
svg.setAttribute('aria-label', `Line chart: ${m.label} by year for three schools and England average`);
document.getElementById('trend-legend').innerHTML =
Object.keys(m.school).map(name => `<span class="li"><span class="sw" style="background:${schoolColor[name]()}"></span>${name}</span>`).join('')
+ `<span class="li"><span class="sw" style="background:none;border-top:2px dashed ${ink3};border-radius:0;height:0"></span>England average</span>`;
}
renderChart('rwm');
document.getElementById('metric-select').addEventListener('change', e => renderChart(e.target.value));
// Re-render on theme change so series colors pick up dark-mode tokens
const rerender = () => location.reload();
new MutationObserver(muts => { if (muts.some(m => m.attributeName === 'data-theme')) rerender(); })
.observe(document.documentElement, { attributes: true });
})();
</script>
@@ -0,0 +1,437 @@
<title>Compare screen — mobile mockup</title>
<style>
:root {
--bg: #faf7f2; --bg-2: #f3ede4; --card: #ffffff;
--ink: #1a1612; --ink-2: #5c564d; --ink-3: #6d685f;
--border: #e5dfd5; --accent: #b04a2e; --accent-bg: rgba(224,114,86,0.12);
--s1: #e07256; --s1-text: #b04a2e;
--s2: #00949b; --s2-text: #006a70;
--s3: #8664c9; --s3-text: #6a4bab;
--eng: #6d685f;
--good-bg: #e3efe6; --good-text: #1a6b34;
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
--neutral-bg: #efeae1; --neutral-text: #5c564d;
--shadow: 0 2px 8px rgba(26,22,18,0.06);
--display: 'Playfair Display', Georgia, 'Times New Roman', serif;
--body: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
--s1: #f08b6e; --s1-text: #f4a58e;
--s2: #2fb8ae; --s2-text: #5fd0c8;
--s3: #a98fe0; --s3-text: #c0abec;
--eng: #9a927f;
--good-bg: #1e3325; --good-text: #7fd39a;
--warn-bg: #38301a; --warn-text: #e4c268;
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
--shadow: 0 2px 8px rgba(0,0,0,0.35);
}
}
:root[data-theme="dark"] {
--bg: #16130f; --bg-2: #201c16; --card: #241f19;
--ink: #f2ede4; --ink-2: #bdb5a8; --ink-3: #9a927f;
--border: #3a342b; --accent: #f08b6e; --accent-bg: rgba(224,114,86,0.16);
--s1: #f08b6e; --s1-text: #f4a58e;
--s2: #2fb8ae; --s2-text: #5fd0c8;
--s3: #a98fe0; --s3-text: #c0abec;
--eng: #9a927f;
--good-bg: #1e3325; --good-text: #7fd39a;
--warn-bg: #38301a; --warn-text: #e4c268;
--neutral-bg: #2b2620; --neutral-text: #bdb5a8;
--shadow: 0 2px 8px rgba(0,0,0,0.35);
}
:root[data-theme="light"] {
--bg: #faf7f2; --bg-2: #f3ede4; --card: #ffffff;
--ink: #1a1612; --ink-2: #5c564d; --ink-3: #6d685f;
--border: #e5dfd5; --accent: #b04a2e; --accent-bg: rgba(224,114,86,0.12);
--s1: #e07256; --s1-text: #b04a2e;
--s2: #00949b; --s2-text: #006a70;
--s3: #8664c9; --s3-text: #6a4bab;
--eng: #6d685f;
--good-bg: #e3efe6; --good-text: #1a6b34;
--warn-bg: #f6ecd4; --warn-text: #7a5d00;
--neutral-bg: #efeae1; --neutral-text: #5c564d;
--shadow: 0 2px 8px rgba(26,22,18,0.06);
}
body { background: var(--bg-2); color: var(--ink); font-family: var(--body); line-height: 1.45; }
/* Phone canvas: true mobile width, framed on larger screens */
.phone { max-width: 400px; margin: 0 auto; background: var(--bg); min-height: 100vh; }
@media (min-width: 480px) {
.phone { margin: 1.5rem auto; border: 1px solid var(--border); border-radius: 24px; overflow: hidden; box-shadow: var(--shadow); }
}
.inner { padding: 1rem 0.9rem 3rem; }
.mock-note {
background: var(--accent-bg); border: 1px solid var(--border); border-radius: 8px;
padding: 0.5rem 0.75rem; font-size: 0.78rem; color: var(--ink-2); margin-bottom: 1rem;
}
.mock-note strong { color: var(--accent); }
h1 { font-family: var(--display); font-size: 1.7rem; font-weight: 700; margin: 0 0 0.2rem; }
.sub { color: var(--ink-2); margin: 0 0 1rem; font-size: 0.88rem; }
/* Sticky school chip bar — horizontal scroll */
.chipbar {
position: sticky; top: 0; z-index: 10; background: var(--bg);
display: flex; gap: 0.5rem; overflow-x: auto; padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border); margin: 0 -0.9rem 1rem; -webkit-overflow-scrolling: touch;
}
.chipbar::-webkit-scrollbar { display: none; }
.schip {
flex: none; display: inline-flex; align-items: center; gap: 0.4rem;
background: var(--card); border: 1px solid var(--border); border-radius: 999px;
padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 600; white-space: nowrap;
}
.schip .dot { width: 9px; height: 9px; border-radius: 50%; }
.schip .x { border: none; background: var(--bg-2); color: var(--ink-3); border-radius: 50%; width: 17px; height: 17px; font-size: 0.7rem; line-height: 1; }
.schip.add { color: var(--accent); border-style: dashed; }
h2.section-title {
font-family: var(--display); font-size: 1.2rem; font-weight: 700;
margin: 1.6rem 0 0; padding-left: 0.6rem; border-left: 3px solid var(--accent);
}
.how { font-size: 0.78rem; color: var(--ink-3); margin: 0.3rem 0 0.75rem 0.8rem; }
/* Measure-first block: one measure, all schools under it */
.measure { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 0.75rem 0.85rem; margin-bottom: 0.6rem; box-shadow: var(--shadow); }
.measure .mt { font-size: 0.85rem; font-weight: 600; display: flex; align-items: center; gap: 0.35rem; }
.measure .mh { font-size: 0.75rem; color: var(--ink-3); margin-top: 0.05rem; }
.srow { display: flex; align-items: center; gap: 0.5rem; padding: 0.45rem 0; border-top: 1px solid var(--border); margin-top: 0.45rem; flex-wrap: wrap; }
.srow:first-of-type { border-top: none; }
.srow .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
.srow .sn { font-size: 0.8rem; font-weight: 600; color: var(--ink-2); width: 4.6rem; flex: none; }
.srow .val { font-weight: 700; font-variant-numeric: tabular-nums; font-size: 0.95rem; }
.srow .note { font-size: 0.74rem; color: var(--ink-3); flex-basis: 100%; padding-left: 1.1rem; margin-top: -0.15rem; }
.chip { display: inline-block; font-size: 0.68rem; font-weight: 600; border-radius: 999px; padding: 0.12rem 0.5rem; white-space: nowrap; }
.chip.good { background: var(--good-bg); color: var(--good-text); }
.chip.warn { background: var(--warn-bg); color: var(--warn-text); }
.chip.neutral { background: var(--neutral-bg); color: var(--neutral-text); }
.ofsted-badge { display: inline-block; font-weight: 700; border-radius: 6px; padding: 0.14rem 0.5rem; font-size: 0.78rem; }
.ofsted-1 { background: var(--good-bg); color: var(--good-text); }
.illus { display: inline-block; font-size: 0.62rem; font-weight: 600; color: var(--accent); border: 1px dashed var(--accent); border-radius: 4px; padding: 0 0.3rem; }
.help { display: inline-flex; width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--ink-3); color: var(--ink-3); font-size: 0.6rem; align-items: center; justify-content: center; flex: none; }
/* Dot strips */
.strip-row { margin: 0.9rem 0 1.3rem; }
.strip-row .t { font-weight: 600; font-size: 0.82rem; }
.strip { position: relative; height: 32px; margin-top: 0.4rem; }
.strip .track { position: absolute; left: 0; right: 0; top: 14px; height: 4px; border-radius: 2px; background: var(--bg-2); }
.strip .eng-tick { position: absolute; top: 4px; width: 2px; height: 24px; background: var(--eng); }
.strip .eng-lbl { position: absolute; top: -12px; transform: translateX(-50%); font-size: 0.62rem; color: var(--ink-3); white-space: nowrap; }
.strip .pt { position: absolute; top: 9px; width: 14px; height: 14px; border-radius: 50%; transform: translateX(-50%); border: 2px solid var(--card); box-shadow: 0 0 0 1px rgba(0,0,0,0.08); }
.strip .pt-lbl { position: absolute; top: 25px; transform: translateX(-50%); font-size: 0.64rem; font-weight: 600; }
.legend { display: flex; gap: 0.8rem; flex-wrap: wrap; font-size: 0.72rem; color: var(--ink-2); margin-top: 0.4rem; }
.legend .li { display: inline-flex; align-items: center; gap: 0.3rem; }
.legend .sw { width: 9px; height: 9px; border-radius: 50%; }
.legend .engsw { width: 2px; height: 11px; background: var(--eng); }
.rc-list { display: flex; flex-direction: column; gap: 0.25rem; width: 100%; padding-left: 1.1rem; }
.rc-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.74rem; }
.rc-row .a { color: var(--ink-2); }
details.more-measures { margin-bottom: 0.6rem; }
details.more-measures summary { cursor: pointer; font-weight: 600; font-size: 0.82rem; color: var(--accent); padding: 0.2rem 0.2rem 0.5rem; }
.strip-note { font-size: 0.72rem; color: var(--ink-3); margin: 0.4rem 0.2rem 0; }
.metric-picker { margin-bottom: 0.6rem; }
.metric-picker select { width: 100%; font-family: inherit; font-size: 0.85rem; padding: 0.45rem 0.6rem; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: var(--ink); }
.chart-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 0.75rem; }
.swipe-hint { font-size: 0.7rem; color: var(--ink-3); text-align: center; margin-top: 0.3rem; }
.covid { fill: var(--bg-2); }
.covid-lbl { font-size: 0.6rem; fill: var(--ink-3); }
.footnote { font-size: 0.7rem; color: var(--ink-3); margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
</style>
<div class="phone"><div class="inner">
<p class="mock-note"><strong>Mobile mockup — proposed /compare.</strong> Mobile-first layout: measures stack vertically with all schools under each, so nothing needs horizontal swiping. Same live data as the desktop mockup.</p>
<h1>Compare schools</h1>
<p class="sub">Anchored against the England average — the grey tick — so you can tell what's typical at a glance.</p>
<div class="chipbar" aria-label="Schools in this comparison">
<span class="schip"><span class="dot" style="background:var(--s1)"></span>Barclay <button class="x" aria-label="Remove Barclay">×</button></span>
<span class="schip"><span class="dot" style="background:var(--s2)"></span>Elmhurst <button class="x" aria-label="Remove Elmhurst">×</button></span>
<span class="schip"><span class="dot" style="background:var(--s3)"></span>Plumcroft <button class="x" aria-label="Remove Plumcroft">×</button></span>
<span class="schip add">+ Add</span>
</div>
<h2 class="section-title">At a glance</h2>
<p class="how">The short version — each measure is explained in its own section below.</p>
<div class="measure">
<div class="mt">Latest Ofsted inspection</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="ofsted-badge ofsted-1">Outstanding</span><span class="note">Older-style inspection, Oct 2021</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="ofsted-badge ofsted-1">Outstanding</span><span class="note">Older-style inspection, Oct 2021</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span><span class="chip good">4 areas Strong standard</span> <span class="chip neutral">2 areas Expected</span> <span class="chip warn">Attendance &amp; behaviour: Attention needed</span> <span class="illus">illustrative</span></span><span class="note">New-style report card, Nov 2025 · safeguarding met · full detail in the Ofsted section below</span></div>
</div>
<div class="measure">
<div class="mt">Children reaching the expected standard <span class="help" title="% of Year 6 pupils reaching the expected standard in reading, writing and maths (2024/25).">?</span></div>
<div class="mh">England average: 62%</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val" style="color:var(--s1-text)">87%</span> <span class="chip good">Above average</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val" style="color:var(--s2-text)">92%</span> <span class="chip good">Above average</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val" style="color:var(--s3-text)">79%</span> <span class="chip good">Above average</span></div>
</div>
<div class="measure">
<div class="mt">Getting a place</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="chip good">97% of first choices offered</span><span class="note">Named on 457 forms · 180 places</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="chip warn">73% of first choices offered</span><span class="note">Named on 342 forms · 120 places</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="chip good">All first choices offered</span><span class="note">Named on 185 forms · 80 places</span></div>
</div>
<h2 class="section-title">Ofsted inspection</h2>
<p class="how">Ofsted stopped giving a single overall grade in September 2024 (inspections until November 2025 kept the area-by-area judgements); from November 2025 new inspections produce a report card rating each area of school life (Exceptional · Strong standard · Expected standard · Attention needed · Urgent improvement). A report card and an older grade aren't directly comparable. Ofsted's "Expected standard" rating is unrelated to the KS2 test measure below.</p>
<div class="measure">
<div class="mt">Latest inspection</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="ofsted-badge ofsted-1">Outstanding</span><span class="chip neutral">4+ years ago</span><span class="note">7 Oct 2021 · we don't hold area-by-area detail for this inspection · <a href="https://reports.ofsted.gov.uk/provider/21/138690" style="color:var(--accent)">Ofsted page →</a></span></div>
<div class="srow" style="align-items:flex-start"><span class="dot" style="background:var(--s2);margin-top:4px"></span><span class="sn">Elmhurst</span><span><span class="ofsted-badge ofsted-1">Outstanding</span> <span class="chip neutral">4+ years ago</span> <span style="font-size:0.72rem;color:var(--ink-3)">6 Oct 2021</span></span>
<div class="rc-list">
<div class="rc-row"><span class="a">Quality of education</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Behaviour &amp; attitudes</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Outstanding</span></div>
<div class="rc-row"><span class="a">Leadership &amp; management</span><span class="chip good">Outstanding</span></div>
</div>
<span class="note"><a href="https://reports.ofsted.gov.uk/provider/21/145362" style="color:var(--accent)">Ofsted page →</a></span>
</div>
<div class="srow" style="align-items:flex-start"><span class="dot" style="background:var(--s3);margin-top:4px"></span><span class="sn">Plumcroft</span><span><strong style="font-size:0.85rem">Report card</strong> <span class="illus">illustrative</span> <span style="font-size:0.72rem;color:var(--ink-3)">14 Nov 2025</span></span>
<div class="rc-list">
<div class="rc-row"><span class="a">Achievement</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Curriculum &amp; teaching</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Attendance &amp; behaviour</span><span class="chip warn">Attention needed</span></div>
<div class="rc-row"><span class="a">Personal development</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Inclusion</span><span class="chip neutral">Expected standard</span></div>
<div class="rc-row"><span class="a">Leadership &amp; governance</span><span class="chip good">Strong standard</span></div>
<div class="rc-row"><span class="a">Early years</span><span class="chip neutral">Expected standard</span></div>
<div class="rc-row"><span class="a">Safeguarding</span><span class="chip good">Met</span></div>
</div>
<span class="note"><a href="https://reports.ofsted.gov.uk/provider/21/100140" style="color:var(--accent)">Ofsted page →</a></span>
</div>
</div>
<h2 class="section-title">How children do academically</h2>
<p class="how">End of Year 6 national tests and teacher assessments (2024/25) — writing is teacher-assessed. Each line runs 0100%; the grey tick is the England average.</p>
<div class="measure" id="strips"></div>
<details class="more-measures">
<summary>More measures — grammar, punctuation &amp; spelling, science, scaled scores</summary>
<div class="measure" id="strips-more" style="margin-top:0.5rem"></div>
<p class="strip-note">Strips show the 100120 window of the full 80120 scaled-score range; 100 is the expected standard (the strip widens if a school averages below it). England ticks for GPS and science aren't in our dataset yet, and the scaled-score ticks are indicative — official DfE figures will be loaded before launch.</p>
</details>
<div class="legend" id="strip-legend" style="padding:0 0.2rem 0"></div>
<div class="measure" style="margin-top:0.9rem">
<div class="mt">Children from lower-income families <span class="help" title="% of disadvantaged pupils (free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. State-school average: 46% (computed from our dataset). Small pupil groups — single pupils can move a school's figure noticeably.">?</span></div>
<div class="mh">State-school average: 46%</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">86%</span> <span class="chip good">Well above average</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">93%</span> <span class="chip good">Well above average</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">72%</span> <span class="chip good">Above average</span></div>
</div>
<h2 class="section-title">Getting a place</h2>
<p class="how">September 2026 entry. "First choice" = families who ranked the school top of their form (officially a "first preference"). Schools never see your ranking — places go by the admission criteria alone. Figures are National Offer Day offers; waiting lists and appeals can change the final intake.</p>
<div class="measure">
<div class="mt">First-choice families offered a place</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">97%</span><span class="note">Named on 457 forms · 180 places</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">73%</span> <span class="chip warn">Over 1 in 4 missed out</span><span class="note">Named on 342 forms · 120 places — check the school's admission criteria (for most non-faith primaries, distance decides)</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">100%</span><span class="note">Named on 185 forms · 80 places · every first choice offered</span></div>
</div>
<h2 class="section-title">Who goes there</h2>
<p class="how">From the latest school census (2025/26). No "right" numbers here — just context.</p>
<div class="measure">
<div class="mt">Pupils on roll</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">1,273</span><span class="note">At or above capacity · much larger than average · girls 51% / boys 49%</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">980</span><span class="note">98% full · much larger than average · girls 48% / boys 52%</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">1,056</span><span class="note">At or above capacity · much larger than average · girls 51% / boys 49%</span></div>
</div>
<div class="measure">
<div class="mt">Free school meals <span class="help" title="% eligible for free school meals. State-school average: 25% (computed from our dataset).">?</span></div>
<div class="mh">State-school average: 25% (our dataset)</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span class="val">26%</span> <span class="chip neutral">About average</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span class="val">25%</span> <span class="chip neutral">About average</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span class="val">30%</span> <span class="chip neutral">A little above</span></div>
</div>
<div class="measure">
<div class="mt">English as an additional language · extra learning support (SEN) <span class="help" title="SEN = pupils receiving SEN support, not including EHC plans. State-school average: ≈14% (computed from our dataset). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign.">?</span></div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span style="font-size:0.85rem">EAL 62% · SEN 6%</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span style="font-size:0.85rem">EAL 84% · SEN 8%</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span style="font-size:0.85rem">EAL 20% · SEN 28% <span class="chip neutral">SEN well above avg</span></span></div>
</div>
<div class="measure">
<div class="mt">Basics</div>
<div class="srow"><span class="dot" style="background:var(--s1)"></span><span class="sn">Barclay</span><span style="font-size:0.85rem">Ages 311 · nursery · no faith · Lion Academy Trust</span></div>
<div class="srow"><span class="dot" style="background:var(--s2)"></span><span class="sn">Elmhurst</span><span style="font-size:0.85rem">Ages 311 · nursery · no faith · New Vision Trust</span></div>
<div class="srow"><span class="dot" style="background:var(--s3)"></span><span class="sn">Plumcroft</span><span style="font-size:0.85rem">Ages 311 · nursery · no faith · Greenwich council</span></div>
</div>
<h2 class="section-title">Explore trends</h2>
<p class="how">Every measure from the current compare page lives on here, grouped. Three are wired up in this mockup. School lines break where a year isn't in our dataset.</p>
<div class="metric-picker">
<select id="metric-select" aria-label="Measure">
<optgroup label="Expected standard">
<option value="rwm" selected>Reading, writing &amp; maths (combined)</option>
<option value="reading">Reading</option>
<option value="maths">Maths</option>
<option disabled>Writing (TA) · GPS · Science (TA)</option>
</optgroup>
<optgroup label="Higher standard"><option disabled>RWM · Reading · Maths · GPS</option><option disabled>Writing — greater depth (TA)</option></optgroup>
<optgroup label="Average scaled scores"><option disabled>Reading · Maths · GPS</option></optgroup>
<optgroup label="Equity"><option disabled>Disadvantaged pupils — expected standard</option></optgroup>
<optgroup label="School context"><option disabled>FSM % · EAL % · SEN support %</option></optgroup>
</select>
</div>
<div class="chart-wrap">
<svg id="trendchart" width="620" height="240" role="img" aria-label="Line chart: selected measure by year for three schools and England average"></svg>
<div class="legend" id="trend-legend"></div>
</div>
<p class="swipe-hint">← swipe the chart →</p>
<p class="footnote">
Sources: DfE Compare School Performance, Ofsted inspection outcomes, DfE admissions data, school census — all from datasets SchoolCompare already collects. England averages for test results are official DfE figures; FSM, language, SEN, size and disadvantaged-pupil benchmarks are computed across state schools in our dataset. Plumcroft's Ofsted report card is a made-up example of the November 2025 format (its real latest inspection is Good, June 2023). Following DfE practice, figures based on 5 or fewer pupils are suppressed and shown as "no data". Static mockup — tooltips and "+ Add" are illustrative.
</p>
</div></div>
<script>
(function () {
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const schools = [
{ name: 'Barclay', color: () => css('--s1'), text: () => css('--s1-text') },
{ name: 'Elmhurst', color: () => css('--s2'), text: () => css('--s2-text') },
{ name: 'Plumcroft', color: () => css('--s3'), text: () => css('--s3-text') },
];
const strips = [
{ label: 'Reading, writing & maths', eng: 62, vals: [87, 92, 79] },
{ label: 'Reading', eng: 75, vals: [91, 92, 87] },
{ label: 'Writing', eng: 72, vals: [94, 92, 83] },
{ label: 'Maths', eng: 74, vals: [91, 95, 84] },
{ label: 'Higher standard', eng: 8, vals: [22, 27, 12], tip: 'A high score in the reading and maths tests plus \u201cgreater depth\u201d in teacher-assessed writing.' },
];
function renderStrips(list, el) {
list.forEach((s) => {
const min = s.min ?? 0, max = s.max ?? 100, unit = s.unit ?? '%';
const pos = v => (v - min) / (max - min) * 100;
const row = document.createElement('div');
row.className = 'strip-row';
let pts = '';
const sorted = s.vals.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v);
let lastBelow = -Infinity;
const nudge = (max - min) * 0.08;
sorted.forEach(({ v, i }) => {
const above = (v - lastBelow) < nudge;
if (!above) lastBelow = v;
pts += `<span class="pt" style="left:${pos(v)}%;background:${schools[i].color()}" title="${schools[i].name}: ${v}${unit}"></span>`
+ `<span class="pt-lbl" style="left:${pos(v)}%;color:${schools[i].text()};${above ? 'top:-6px' : ''}">${v}</span>`;
});
const eng = s.eng != null
? `<span class="eng-tick" style="left:${pos(s.eng)}%"></span><span class="eng-lbl" style="left:${pos(s.eng)}%">Eng ${s.eng}${unit}</span>`
: '';
row.innerHTML = `
<div class="t"${s.tip ? ` title="${s.tip}"` : ''}>${s.label}</div>
<div class="strip" role="img" aria-label="${s.label}: ${s.eng != null ? 'England average ' + s.eng + unit + ', ' : ''}${s.vals.map((v, i) => schools[i].name + ' ' + v + unit).join(', ')}">
<div class="track"></div>
${eng}
${pts}
</div>`;
el.appendChild(row);
});
}
renderStrips(strips, document.getElementById('strips'));
renderStrips([
{ label: 'Grammar, punctuation & spelling', eng: null, vals: [86, 94, 82] },
{ label: 'Science (teacher-assessed)', eng: null, vals: [95, 91, 83], tip: 'Teacher-assessed, like writing — no KS2 science test since 2009; comparisons are indicative.' },
{ label: 'Avg scaled score — reading', eng: 106, vals: [107, 110, 109], min: 100, max: 120, unit: '' },
{ label: 'Avg scaled score — maths', eng: 105, vals: [108, 113, 108], min: 100, max: 120, unit: '' },
{ label: 'Avg scaled score — GPS', eng: 105, vals: [107, 114, 109], min: 100, max: 120, unit: '' },
], document.getElementById('strips-more'));
document.getElementById('strip-legend').innerHTML =
schools.map(s => `<span class="li"><span class="sw" style="background:${s.color()}"></span>${s.name}</span>`).join('')
+ '<span class="li"><span class="engsw"></span>England average</span>';
/* Metric-driven trend chart. Lines always break across the covid band
(2019/20-2020/21, cancelled tests); other nulls are dataset gaps. */
const years = ['2015/16','2016/17','2017/18','2018/19','2021/22','2022/23','2023/24','2024/25'];
const METRICS = {
rwm: { school: { Barclay: [52,76,65,87,null,87,75,87], Elmhurst: [80,81,86,88,null,88,88,92], Plumcroft: [58,64,57,69,null,62,72,79] },
england: [null,61.1,64.3,64.9,58.7,59.5,60.6,62.1] },
reading: { school: { Barclay: [57,84,76,87,null,null,79,91], Elmhurst: [88,91,92,91,null,null,92,92], Plumcroft: [59,68,72,79,null,null,82,87] },
england: [null,71.6,75.3,73.2,74.6,72.8,74.4,75.0] },
maths: { school: { Barclay: [81,82,74,90,null,null,88,91], Elmhurst: [96,89,97,97,null,null,96,95], Plumcroft: [80,80,69,90,null,null,84,84] },
england: [null,74.8,75.5,78.7,71.5,73.0,73.2,74.0] },
};
const schoolColor = { Barclay: () => css('--s1'), Elmhurst: () => css('--s2'), Plumcroft: () => css('--s3') };
const svg = document.getElementById('trendchart');
const W = 620, H = 240, L = 38, R = 44, T = 14, B = 34;
const slots = [0,1,2,3,6,7,8,9]; const maxSlot = 9;
const X = i => L + slots[i] * (W - L - R) / maxSlot;
const Y = v => T + (100 - v) * (H - T - B) / 60;
function runs(vals) {
const out = []; let cur = [];
vals.forEach((v, i) => {
if (v == null) { if (cur.length) out.push(cur); cur = []; return; }
if (i === 4 && cur.length && cur[cur.length - 1] === 3) { out.push(cur); cur = []; }
cur.push(i);
});
if (cur.length) out.push(cur);
return out;
}
function renderChart(key) {
const m = METRICS[key];
const ink3 = css('--ink-3'), border = css('--border');
let g = '';
const bx1 = X(3) + 12, bx2 = X(4) - 12;
g += `<rect class="covid" x="${bx1}" y="${T}" width="${bx2 - bx1}" height="${H - T - B}" rx="4"/>`;
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 14}" text-anchor="middle">tests cancelled</text>`;
g += `<text class="covid-lbl" x="${(bx1 + bx2) / 2}" y="${T + 26}" text-anchor="middle">'19/20-'20/21</text>`;
for (let v = 40; v <= 100; v += 20) {
g += `<line x1="${L}" y1="${Y(v)}" x2="${W - R}" y2="${Y(v)}" stroke="${border}" stroke-width="1"/>`;
g += `<text x="${L - 6}" y="${Y(v) + 4}" text-anchor="end" font-size="10" fill="${ink3}">${v}%</text>`;
}
years.forEach((yr, i) => {
g += `<text x="${X(i)}" y="${H - B + 18}" text-anchor="middle" font-size="9.5" fill="${ink3}">${yr}</text>`;
});
const path = (vals, idx) => idx.map((i, k) => (k ? 'L' : 'M') + X(i) + ' ' + Y(vals[i])).join(' ');
runs(m.england).forEach(idx => {
if (idx.length > 1) g += `<path d="${path(m.england, idx)}" fill="none" stroke="${ink3}" stroke-width="1.5" stroke-dasharray="5 4"/>`;
else g += `<circle cx="${X(idx[0])}" cy="${Y(m.england[idx[0]])}" r="2" fill="${ink3}"><title>England ${years[idx[0]]}: ${Math.round(m.england[idx[0]])}%</title></circle>`;
});
Object.entries(m.school).forEach(([name, vals]) => {
const color = schoolColor[name]();
runs(vals).forEach(idx => {
if (idx.length > 1) g += `<path d="${path(vals, idx)}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
});
vals.forEach((v, i) => {
if (v == null) return;
g += `<circle cx="${X(i)}" cy="${Y(v)}" r="3" fill="${color}" stroke="${css('--card')}" stroke-width="1.5"><title>${name} ${years[i]}: ${v}%</title></circle>`;
});
});
const ends = Object.entries(m.school)
.map(([name, vals]) => ({ v: vals[vals.length - 1], i: vals.length - 1 }))
.filter(e => e.v != null)
.sort((a, b) => a.v - b.v);
let prevY = Infinity;
ends.forEach(e => {
let y = Y(e.v) + 3.5;
if (prevY - y < 12) y = prevY - 12;
prevY = y;
g += `<text x="${X(e.i) + 7}" y="${y}" font-size="10" font-weight="600" fill="${ink3}">${e.v}%</text>`;
});
svg.innerHTML = g;
document.getElementById('trend-legend').innerHTML =
Object.keys(m.school).map(name => `<span class="li"><span class="sw" style="background:${schoolColor[name]()}"></span>${name}</span>`).join('')
+ `<span class="li"><span class="sw" style="background:none;border-top:2px dashed ${ink3};border-radius:0;height:0"></span>England avg</span>`;
}
renderChart('rwm');
document.getElementById('metric-select').addEventListener('change', e => renderChart(e.target.value));
new MutationObserver(muts => { if (muts.some(m => m.attributeName === 'data-theme')) location.reload(); })
.observe(document.documentElement, { attributes: true });
})();
</script>