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
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:
@@ -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`: 100–120 domain maps 106→30; values 91 and 92 on 0–100 → 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/20–2020/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 196–240 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/20–2021/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 4th–6th
|
||||
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 2024–Nov 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 1–5 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 1–5
|
||||
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 80–120", strips render 100–120;
|
||||
truncated domain exaggerates small gaps and below-100 averages
|
||||
would fall off the edge. Render 80–120, or caption the 100–120
|
||||
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 2024–Nov 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
|
||||
(80–120 vs 100–120), one provenance inconsistency, one terminology
|
||||
error (writing/GDS). With findings 1–3 and 6–7 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/20–2020/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 100–120 window of the 80–120 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/20–2020/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 & 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 & 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 & 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 & teaching</span><span class="chip good">Strong standard</span></div>
|
||||
<div class="rc-row"><span class="a">Attendance & 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 & 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 0–100%; 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 & spelling, science, average scaled scores</summary>
|
||||
<div id="strips-more"></div>
|
||||
<p class="strip-note">The strips show the 100–120 window of the full 80–120 scaled-score range; 100 is the expected standard, and the strip widens if a school averages below it. England ticks for grammar, punctuation & 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)">3–11 · has a nursery</div>
|
||||
<div class="cell" data-school="Elmhurst" style="--sc:var(--s2-text)">3–11 · has a nursery</div>
|
||||
<div class="cell" data-school="Plumcroft" style="--sc:var(--s3-text)">3–11 · 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 & maths (combined)</option>
|
||||
<option value="reading">Reading</option>
|
||||
<option value="maths">Maths</option>
|
||||
<option disabled>Writing (teacher assessment)</option>
|
||||
<option disabled>Grammar, punctuation & spelling</option>
|
||||
<option disabled>Science</option>
|
||||
</optgroup>
|
||||
<optgroup label="Higher standard">
|
||||
<option disabled>Reading, writing & 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/16–18/19 then 22/23–24/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 & 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 & 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 & 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 & teaching</span><span class="chip good">Strong standard</span></div>
|
||||
<div class="rc-row"><span class="a">Attendance & 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 & 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 0–100%; the grey tick is the England average.</p>
|
||||
<div class="measure" id="strips"></div>
|
||||
<details class="more-measures">
|
||||
<summary>More measures — grammar, punctuation & spelling, science, scaled scores</summary>
|
||||
<div class="measure" id="strips-more" style="margin-top:0.5rem"></div>
|
||||
<p class="strip-note">Strips show the 100–120 window of the full 80–120 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 3–11 · 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 3–11 · 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 3–11 · 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 & 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>
|
||||
Reference in New Issue
Block a user