Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993a6133cf | ||
|
|
200a97d0b9 | ||
|
|
f3fa12806b | ||
|
|
1004f08daf | ||
|
|
79246edc22 | ||
|
|
64b63b96c8 | ||
|
|
5944d88f0b | ||
|
|
163b501be6 | ||
|
|
80176cac4d | ||
|
|
84baf95f68 | ||
|
|
99b769ca9e | ||
|
|
e8f78a1598 | ||
|
|
8e0b730629 | ||
|
|
20a27f3958 | ||
|
|
852ed11e4d | ||
|
|
77d7052662 | ||
|
|
c9e324635b | ||
|
|
1d855f3c17 | ||
|
|
9773483221 | ||
|
|
e00a1b38a8 | ||
|
|
026a7ab6aa | ||
|
|
a86a2be96c | ||
|
|
d98e88f0b4 | ||
|
|
609bb923d9 | ||
|
|
4bfcd9ba9a | ||
|
|
95f10bf352 | ||
|
|
674470ceb6 | ||
|
|
8abff7a0a1 | ||
|
|
6f62c25f47 | ||
|
|
e74d3882ce | ||
|
|
3fb3db1cc4 | ||
|
|
b4b0249a06 | ||
|
|
e39aef2935 | ||
|
|
fef83b3bf2 | ||
|
|
cf458fe05c | ||
|
|
f579630fab | ||
|
|
19b41b6999 | ||
|
|
66bc5523f6 | ||
|
|
3cb72d0a0f | ||
|
|
b89fa47ec5 | ||
|
|
0c7ad0f309 | ||
|
|
e4565e9f15 | ||
|
|
06e4898c30 | ||
|
|
a9611e21c3 | ||
|
|
0696518995 | ||
|
|
090d5f7bec | ||
|
|
315f1feede | ||
|
|
abc03a0dd3 | ||
|
|
43a2c4a6bc | ||
|
|
8e4ee64140 | ||
|
|
d2dc78aeb5 | ||
|
|
619e3a1189 | ||
|
|
52f8994401 | ||
|
|
9990f540f7 | ||
|
|
6dd9b04b50 | ||
|
|
d0e71e2cf0 | ||
|
|
6138e2b2ee | ||
|
|
2f85b9c647 | ||
|
|
2155256177 | ||
|
|
519584f34b | ||
|
|
2573cd2490 | ||
|
|
9f2260ce50 | ||
|
|
4a8e798c64 | ||
|
|
60cbc3f46d | ||
|
|
48ca042b08 | ||
|
|
80f057ea5a | ||
|
|
9447b80bc6 | ||
|
|
c0f31a5941 | ||
|
|
cec7941b44 | ||
|
|
dbaa15c099 | ||
|
|
b5b47ca135 | ||
|
|
0c89b2c34e | ||
|
|
4bf90b5f09 | ||
|
|
17b4498c80 | ||
|
|
3754947fd6 | ||
|
|
9799ad9b43 | ||
|
|
d5cd0abfee | ||
|
|
6877abedeb | ||
|
|
436ec6151b | ||
|
|
2b563cc0bf | ||
|
|
75e92dc7f5 | ||
|
|
7499e7f557 | ||
|
|
dd0ff7d0c2 | ||
|
|
6f925abf6b | ||
|
|
03518520f8 | ||
|
|
c2ed002118 | ||
|
|
02084e427c | ||
|
|
bee63a7836 | ||
|
|
fc21783298 | ||
|
|
ccd8e73fe8 | ||
|
|
5f1b6adb44 | ||
|
|
34a5de2687 | ||
|
|
af43b291e7 | ||
|
|
5a94f470e1 | ||
|
|
0fe1ea0d6a | ||
|
|
297bdbd12e | ||
|
|
58e90fef61 | ||
|
|
3710529e49 | ||
|
|
159207c6f5 | ||
|
|
d677b54533 | ||
|
|
c353e36072 | ||
|
|
d9223a6d6e | ||
|
|
74ca76d150 | ||
|
|
4b75152ee0 | ||
|
|
84dfc6c1bb |
@@ -1,4 +1,4 @@
|
||||
name: Deploy (staging -> E2E gate -> production)
|
||||
name: Stage (build -> staging -> E2E gate)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -193,48 +193,5 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
|
||||
promote-prod:
|
||||
name: Promote to Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: [e2e-staging]
|
||||
steps:
|
||||
- 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 verified images as prod
|
||||
run: |
|
||||
SHORT_SHA="sha-$(echo "${{ gitea.sha }}" | cut -c1-7)"
|
||||
for IMAGE in \
|
||||
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
|
||||
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
|
||||
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
|
||||
# Keep a rollback pointer before moving :prod
|
||||
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 }}
|
||||
# Production deployment is a second, manual approval: see promote.yml
|
||||
# ("Promote to Production (manual)") and docs/DEPLOY.md.
|
||||
|
||||
@@ -5,6 +5,13 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Cancel superseded runs: pushing a new commit to a PR (or an empty
|
||||
# re-trigger) aborts the previous still-running checks instead of running
|
||||
# a second full matrix alongside them.
|
||||
concurrency:
|
||||
group: pr-checks-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: privaterepo.sitaru.org
|
||||
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
||||
@@ -23,12 +30,22 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: nextjs-app/package-lock.json
|
||||
|
||||
# Cache the resolved node_modules (452 MB / 460 packages) keyed on the
|
||||
# lockfile. On a hit — the common case, since deps change rarely — the
|
||||
# whole `npm ci` step is skipped, not just its download phase. The key
|
||||
# pins OS + node major so we never restore incompatible native binaries.
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: nextjs-app/node_modules
|
||||
key: nextjs-node-modules-${{ runner.os }}-node22-${{ hashFiles('nextjs-app/package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
working-directory: nextjs-app
|
||||
run: npm ci
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: nextjs-app
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Promote to Production (manual)
|
||||
|
||||
# Second approval gate of the deploy model: run this workflow from the
|
||||
# Actions UI after testing the feature on staging. It refuses commits
|
||||
# whose staging E2E gate is not green. See docs/DEPLOY.md.
|
||||
|
||||
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: ""
|
||||
|
||||
# Only one promotion at a time; never cancel an in-flight promotion.
|
||||
concurrency:
|
||||
group: prod-promotion
|
||||
cancel-in-progress: false
|
||||
|
||||
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: Checkout repository (full history for ancestry check)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve and validate target SHA
|
||||
id: resolve
|
||||
# SECURITY: the dispatch input is untrusted — it reaches the shell
|
||||
# only via env (never spliced into `run:` with ${{ }}) and is only
|
||||
# used as a quoted argument. The resolved value is validated as a
|
||||
# 40-hex sha and required to be an ancestor of main before any
|
||||
# later step interpolates it.
|
||||
env:
|
||||
SHA_INPUT: ${{ gitea.event.inputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$SHA_INPUT" in
|
||||
-*) echo "REFUSED: SHA input may not start with '-'." >&2; exit 1 ;;
|
||||
esac
|
||||
if [ -z "$SHA_INPUT" ]; then
|
||||
SHA_INPUT="$(git rev-parse origin/main)"
|
||||
fi
|
||||
FULL_SHA=$(git rev-parse --verify --quiet "${SHA_INPUT}^{commit}") || {
|
||||
echo "REFUSED: not a commit in this repository." >&2
|
||||
exit 1
|
||||
}
|
||||
echo "$FULL_SHA" | grep -Eq '^[0-9a-f]{40}$'
|
||||
if ! git merge-base --is-ancestor "$FULL_SHA" origin/main; then
|
||||
echo "REFUSED: $FULL_SHA is not on main — only main commits are promotable." >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
# Keep a rollback pointer before moving :prod
|
||||
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 }}
|
||||
@@ -1,2 +1,7 @@
|
||||
venv
|
||||
__pycache__/
|
||||
|
||||
# dbt local build artifacts (embed absolute paths + anonymous-usage UUID)
|
||||
pipeline/transform/target/
|
||||
pipeline/transform/logs/
|
||||
pipeline/transform/.user.yml
|
||||
|
||||
+146
-70
@@ -25,10 +25,12 @@ import asyncio
|
||||
from .config import settings
|
||||
from .data_loader import (
|
||||
clear_cache,
|
||||
compute_benchmarks,
|
||||
load_school_data,
|
||||
load_latest_school_data,
|
||||
geocode_single_postcode,
|
||||
get_supplementary_data,
|
||||
get_supplementary_data_batch,
|
||||
search_schools_typesense,
|
||||
)
|
||||
from .data_loader import get_data_info as get_db_info
|
||||
@@ -662,6 +664,62 @@ async def compare_schools(
|
||||
if comparison_data.empty:
|
||||
raise HTTPException(status_code=404, detail="No schools found")
|
||||
|
||||
# One session for all schools' supplementary blocks; failures degrade
|
||||
# to empty blocks rather than failing a working comparison (mirrors
|
||||
# the detail endpoint's defensive pattern).
|
||||
from . import database
|
||||
|
||||
_EMPTY_SUPPLEMENTARY = {
|
||||
"ofsted": None,
|
||||
"census": None,
|
||||
"admissions": None,
|
||||
"admissions_history": [],
|
||||
"deprivation": None,
|
||||
}
|
||||
supplementary_by_urn: dict = {}
|
||||
census_benchmarks = None
|
||||
db = None
|
||||
try:
|
||||
db = database.SessionLocal()
|
||||
# One query per table for all schools, not ~5 queries per school.
|
||||
batch = get_supplementary_data_batch(db, urn_list)
|
||||
for urn in urn_list:
|
||||
supp = batch.get(urn, {})
|
||||
supplementary_by_urn[urn] = {
|
||||
key: supp.get(key, default)
|
||||
for key, default in _EMPTY_SUPPLEMENTARY.items()
|
||||
}
|
||||
# Import-time census context benchmarks (fact_census_benchmarks);
|
||||
# absent mart → None, and compute_benchmarks leaves those fields null.
|
||||
try:
|
||||
from .models import CensusBenchmark
|
||||
|
||||
rows = db.query(CensusBenchmark).all()
|
||||
by_phase = {
|
||||
r.phase: {
|
||||
"year": r.year,
|
||||
"fsm_pct": r.fsm_pct,
|
||||
"eal_pct": r.eal_pct,
|
||||
"median_pupils": r.median_pupils,
|
||||
}
|
||||
for r in rows
|
||||
if getattr(r, "phase", None) in ("primary", "secondary")
|
||||
}
|
||||
if by_phase:
|
||||
census_benchmarks = by_phase
|
||||
except Exception:
|
||||
# Missing mart (or a stubbed session in tests) must never break
|
||||
# the compare payload — and not every session has rollback().
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
supplementary_by_urn = {}
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
result = {}
|
||||
for urn in urn_list:
|
||||
school_data = comparison_data[comparison_data["urn"] == urn].sort_values("year")
|
||||
@@ -677,11 +735,30 @@ async def compare_schools(
|
||||
"phase": latest.get("phase", ""),
|
||||
"attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None,
|
||||
"rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None,
|
||||
# GIAS facts the compare "Who goes there" section needs
|
||||
# (same fields the detail endpoint exposes)
|
||||
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
|
||||
"age_range": convert_to_native(latest.get("age_range")),
|
||||
"gender": convert_to_native(latest.get("gender")),
|
||||
# Needed by the admissions "What this means" copy: selective
|
||||
# schools get entrance-test framing, never the distance template.
|
||||
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
|
||||
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
|
||||
"capacity": convert_to_native(latest.get("capacity")),
|
||||
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
|
||||
"trust_name": convert_to_native(latest.get("trust_name")),
|
||||
},
|
||||
"yearly_data": clean_for_json(school_data),
|
||||
**supplementary_by_urn.get(urn, dict(_EMPTY_SUPPLEMENTARY)),
|
||||
}
|
||||
|
||||
return {"comparison": result}
|
||||
return {
|
||||
"comparison": result,
|
||||
# Official DfE anchors + computed state-school benchmarks so the
|
||||
# compare UI can label provenance correctly (spec §8.6).
|
||||
"national_averages": _national_averages_payload(df),
|
||||
"benchmarks": compute_benchmarks(df, census_benchmarks=census_benchmarks),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/filters")
|
||||
@@ -727,96 +804,84 @@ async def get_la_averages(request: Request):
|
||||
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
|
||||
|
||||
|
||||
@app.get("/api/national-averages")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_national_averages(request: Request):
|
||||
"""
|
||||
Compute national average for each metric from the latest data year.
|
||||
Returns separate averages for primary (KS2) and secondary (KS4) schools.
|
||||
Values are derived from the loaded DataFrame so they automatically
|
||||
stay current when new data is loaded.
|
||||
"""
|
||||
df = load_school_data()
|
||||
if df.empty:
|
||||
return {"primary": {}, "secondary": {}}
|
||||
|
||||
ks2_metrics = [
|
||||
_KS2_NATIONAL_METRICS = [
|
||||
"rwm_expected_pct", "rwm_high_pct",
|
||||
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
||||
"gps_expected_pct", "gps_high_pct", "science_expected_pct",
|
||||
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||
"reading_progress", "writing_progress", "maths_progress",
|
||||
"overall_absence_pct", "persistent_absence_pct",
|
||||
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct",
|
||||
]
|
||||
ks4_metrics = [
|
||||
_KS4_NATIONAL_METRICS = [
|
||||
"attainment_8_score", "progress_8_score",
|
||||
"english_maths_standard_pass_pct", "english_maths_strong_pass_pct",
|
||||
"ebacc_entry_pct", "ebacc_standard_pass_pct", "ebacc_strong_pass_pct",
|
||||
"ebacc_avg_score", "gcse_grade_91_pct",
|
||||
]
|
||||
|
||||
def _means(sub_df, metric_list):
|
||||
out = {}
|
||||
for col in metric_list:
|
||||
if col in sub_df.columns:
|
||||
val = sub_df[col].dropna()
|
||||
if len(val) > 0:
|
||||
out[col] = round(float(val.mean()), 2)
|
||||
return out
|
||||
|
||||
def _national_averages_payload(df: pd.DataFrame) -> dict:
|
||||
"""National-averages payload shared by /api/national-averages and
|
||||
/api/compare.
|
||||
|
||||
Both series are persisted marts computed at import time: official DfE
|
||||
KS2 figures (fact_ks2_national_averages) and official DfE KS4 figures
|
||||
(fact_ks4_national_averages) — the API never aggregates the performance
|
||||
dataframe per request. If the KS4 mart hasn't been built yet, the
|
||||
secondary series is empty — never a computed stand-in, because the UI
|
||||
labels these figures as official DfE data.
|
||||
"""
|
||||
if df.empty:
|
||||
return {"primary": {}, "secondary": {}}
|
||||
|
||||
latest_year = int(df["year"].max())
|
||||
df_latest = df[df["year"] == latest_year]
|
||||
|
||||
# Primary: schools where KS2 data is non-null
|
||||
primary_df = df_latest[df_latest["rwm_expected_pct"].notna()]
|
||||
# Secondary: schools where KS4 data is non-null
|
||||
secondary_df = df_latest[df_latest["attainment_8_score"].notna()]
|
||||
from . import database
|
||||
from .models import Ks2NationalAverage, Ks4NationalAverage
|
||||
|
||||
latest_primary = _means(primary_df, ks2_metrics)
|
||||
latest_secondary = _means(secondary_df, ks4_metrics)
|
||||
|
||||
# Per-year KS2 primary averages: use official DfE figures from the mart table.
|
||||
# Per-year KS4 secondary averages: computed from our dataset (no DfE dataset yet).
|
||||
from .database import SessionLocal
|
||||
from .models import Ks2NationalAverage
|
||||
|
||||
by_year = []
|
||||
try:
|
||||
db = SessionLocal()
|
||||
nat_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
|
||||
# Build a lookup of computed secondary averages per year as fallback
|
||||
secondary_by_year = {}
|
||||
for yr in sorted(df["year"].dropna().unique()):
|
||||
yr = int(yr)
|
||||
df_yr = df[df["year"] == yr]
|
||||
secondary_by_year[yr] = _means(
|
||||
df_yr[df_yr["attainment_8_score"].notna()], ks4_metrics
|
||||
)
|
||||
# Merge: official KS2 figures + computed KS4 figures per year
|
||||
ks2_years = {r.year for r in nat_rows}
|
||||
all_years = sorted(ks2_years | set(secondary_by_year.keys()))
|
||||
nat_lookup = {r.year: r for r in nat_rows}
|
||||
for yr in all_years:
|
||||
primary_yr: dict = {}
|
||||
if yr in nat_lookup:
|
||||
r = nat_lookup[yr]
|
||||
for col in ks2_metrics:
|
||||
val = getattr(r, col, None)
|
||||
def _row_metrics(row, metric_list):
|
||||
out = {}
|
||||
for col in metric_list:
|
||||
val = getattr(row, col, None)
|
||||
if val is not None:
|
||||
primary_yr[col] = val
|
||||
by_year.append({
|
||||
"year": yr,
|
||||
"primary": primary_yr,
|
||||
"secondary": secondary_by_year.get(yr, {}),
|
||||
})
|
||||
out[col] = val
|
||||
return out
|
||||
|
||||
ks2_rows: list = []
|
||||
ks4_rows: list = []
|
||||
db = None
|
||||
try:
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
ks2_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
try:
|
||||
ks4_rows = db.query(Ks4NationalAverage).order_by(Ks4NationalAverage.year).all()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
# Update latest_primary with official DfE figure for the latest year if available
|
||||
if by_year:
|
||||
latest_official = next((e["primary"] for e in reversed(by_year) if e["primary"]), None)
|
||||
if latest_official:
|
||||
latest_primary = latest_official
|
||||
primary_by_year = {r.year: _row_metrics(r, _KS2_NATIONAL_METRICS) for r in ks2_rows}
|
||||
secondary_by_year = {r.year: _row_metrics(r, _KS4_NATIONAL_METRICS) for r in ks4_rows}
|
||||
|
||||
all_years = sorted(set(primary_by_year) | set(secondary_by_year))
|
||||
by_year = [
|
||||
{
|
||||
"year": yr,
|
||||
"primary": primary_by_year.get(yr, {}),
|
||||
"secondary": secondary_by_year.get(yr, {}),
|
||||
}
|
||||
for yr in all_years
|
||||
]
|
||||
|
||||
latest_primary = next((e["primary"] for e in reversed(by_year) if e["primary"]), {})
|
||||
latest_secondary = next((e["secondary"] for e in reversed(by_year) if e["secondary"]), {})
|
||||
|
||||
return {
|
||||
"year": latest_year,
|
||||
@@ -826,6 +891,17 @@ async def get_national_averages(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/national-averages")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_national_averages(request: Request):
|
||||
"""
|
||||
National averages: official DfE KS2 figures per year plus computed
|
||||
KS4 averages, derived from the loaded DataFrame and the
|
||||
fact_ks2_national_averages mart.
|
||||
"""
|
||||
return _national_averages_payload(load_school_data())
|
||||
|
||||
|
||||
@app.get("/api/metrics")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_available_metrics(request: Request):
|
||||
|
||||
+307
-81
@@ -4,6 +4,7 @@ Provides efficient queries with caching.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
@@ -20,6 +21,7 @@ from .models import (
|
||||
FactOfstedInspection, FactAdmissions,
|
||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||
)
|
||||
from .ofsted_codes import ofsted_page_url, report_card_labels
|
||||
from .schemas import SCHOOL_TYPE_MAP
|
||||
from .gias_codes import (
|
||||
ADMISSIONS_POLICY,
|
||||
@@ -189,13 +191,20 @@ _MAIN_QUERY = text("""
|
||||
p.reading_high_pct,
|
||||
p.reading_avg_score,
|
||||
p.reading_progress,
|
||||
p.reading_progress_lower_ci,
|
||||
p.reading_progress_upper_ci,
|
||||
p.writing_expected_pct,
|
||||
p.writing_high_pct,
|
||||
p.writing_progress,
|
||||
p.writing_progress_lower_ci,
|
||||
p.writing_progress_upper_ci,
|
||||
p.writing_working_towards_pct,
|
||||
p.maths_expected_pct,
|
||||
p.maths_high_pct,
|
||||
p.maths_avg_score,
|
||||
p.maths_progress,
|
||||
p.maths_progress_lower_ci,
|
||||
p.maths_progress_upper_ci,
|
||||
p.gps_expected_pct,
|
||||
p.gps_high_pct,
|
||||
p.gps_avg_score,
|
||||
@@ -224,6 +233,9 @@ _MAIN_QUERY = text("""
|
||||
p.progress_8_maths,
|
||||
p.progress_8_ebacc,
|
||||
p.progress_8_open,
|
||||
p.progress_8_banding,
|
||||
p.attainment_8_disadvantage_gap,
|
||||
p.progress_8_disadvantage_gap,
|
||||
p.english_maths_strong_pass_pct,
|
||||
p.english_maths_standard_pass_pct,
|
||||
p.ebacc_entry_pct,
|
||||
@@ -262,15 +274,68 @@ assert "NULL AS has_sixth_form" in str(_MAIN_QUERY_NO_SIXTH_FORM), (
|
||||
"expected replacement of 's.has_sixth_form,' to have taken effect"
|
||||
)
|
||||
|
||||
# Fallback used when marts.dim_school predates the GIAS code-dictionary
|
||||
# migration (i.e. the nightly dbt pipeline hasn't rebuilt the mart yet on
|
||||
# this DB, so it still has the old name columns instead of *_code columns).
|
||||
_MAIN_QUERY_LEGACY_NAMES = str(_MAIN_QUERY)
|
||||
_LEGACY_NAME_REPLACEMENTS = [
|
||||
("s.phase_code,", "s.phase,"),
|
||||
("s.school_type_code,", "s.school_type,"),
|
||||
(
|
||||
"s.religious_character_code,",
|
||||
"s.religious_character AS religious_denomination,",
|
||||
),
|
||||
("s.status_code,", "s.status,"),
|
||||
("s.admissions_policy_code,", "s.admissions_policy,"),
|
||||
]
|
||||
for _old, _new in _LEGACY_NAME_REPLACEMENTS:
|
||||
assert _old in _MAIN_QUERY_LEGACY_NAMES, (
|
||||
f"expected {_old!r} to be present in _MAIN_QUERY before replacement"
|
||||
)
|
||||
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
|
||||
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
||||
|
||||
_GIAS_CODE_COLUMN_NAMES = (
|
||||
"phase_code",
|
||||
"school_type_code",
|
||||
"religious_character_code",
|
||||
"status_code",
|
||||
"admissions_policy_code",
|
||||
)
|
||||
|
||||
_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist')
|
||||
|
||||
|
||||
def _missing_column_name(exc: Exception) -> Optional[str]:
|
||||
"""Name of the missing column from a psycopg2 UndefinedColumn error.
|
||||
|
||||
Inspects exc.orig (the DBAPI error), whose message names only the
|
||||
offending column — str(exc) also embeds the full SQL statement, which
|
||||
contains every column name and therefore must not be matched against.
|
||||
"""
|
||||
orig = getattr(exc, "orig", None)
|
||||
match = _MISSING_COLUMN_RE.search(str(orig) if orig is not None else str(exc))
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def load_school_data_as_dataframe() -> pd.DataFrame:
|
||||
"""Load all school + KS2 data as a pandas DataFrame."""
|
||||
try:
|
||||
df = pd.read_sql(_MAIN_QUERY, engine)
|
||||
except sqlalchemy.exc.ProgrammingError as exc:
|
||||
if "has_sixth_form" not in str(exc):
|
||||
print(f"Warning: Could not load school data from marts: {exc}")
|
||||
missing = _missing_column_name(exc)
|
||||
if missing in _GIAS_CODE_COLUMN_NAMES:
|
||||
logging.getLogger(__name__).warning(
|
||||
"marts predate the GIAS code migration — falling back to "
|
||||
"legacy name-column query: %s",
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
df = pd.read_sql(_MAIN_QUERY_LEGACY_NAMES, engine)
|
||||
except Exception as exc2:
|
||||
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||
return pd.DataFrame()
|
||||
elif missing == "has_sixth_form":
|
||||
logging.getLogger(__name__).warning(
|
||||
"marts.dim_school is missing has_sixth_form (pipeline hasn't "
|
||||
"rebuilt the mart yet on this DB) — retrying without it: %s",
|
||||
@@ -281,6 +346,9 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
||||
except Exception as exc2:
|
||||
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||
return pd.DataFrame()
|
||||
else:
|
||||
print(f"Warning: Could not load school data from marts: {exc}")
|
||||
return pd.DataFrame()
|
||||
except Exception as exc:
|
||||
print(f"Warning: Could not load school data from marts: {exc}")
|
||||
return pd.DataFrame()
|
||||
@@ -457,37 +525,109 @@ def get_data_info(db: Session = None) -> dict:
|
||||
# SUPPLEMENTARY DATA — per-school detail page
|
||||
# =============================================================================
|
||||
|
||||
def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
"""Fetch all supplementary data for a single school URN."""
|
||||
result = {}
|
||||
def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None) -> dict:
|
||||
"""State-school benchmarks computed from our dataset (spec §5/§8.6).
|
||||
|
||||
def safe_query(model, pk_field, latest_field=None):
|
||||
try:
|
||||
q = db.query(model).filter(getattr(model, pk_field) == urn)
|
||||
if latest_field:
|
||||
q = q.order_by(getattr(model, latest_field).desc())
|
||||
return q.first()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error("safe_query failed for %s: %s", model.__name__, e)
|
||||
db.rollback()
|
||||
NOT official DfE figures — consumers must label them
|
||||
"state-school average (computed from our dataset)". The disadvantaged
|
||||
attainment average is weighted by cohort size (eligible_pupils) so
|
||||
small schools don't dominate.
|
||||
|
||||
Context measures (FSM/EAL/pupil counts) come from `census_benchmarks`
|
||||
(the fact_census_benchmarks mart, pupil-weighted, keyed by phase): the
|
||||
performance df has no fsm_pct at all, and its eal/disadvantaged columns
|
||||
are KS2-only — medianing them for "secondary" produced junk anchors
|
||||
from the handful of all-through schools. When the mart is unavailable
|
||||
these are None; never fall back across measure definitions.
|
||||
"""
|
||||
if df.empty or "year" not in df.columns:
|
||||
return {}
|
||||
latest_year = df["year"].max()
|
||||
if pd.isna(latest_year):
|
||||
return {}
|
||||
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
|
||||
|
||||
# Latest Ofsted inspection
|
||||
o = safe_query(FactOfstedInspection, "urn", "inspection_date")
|
||||
result["ofsted"] = (
|
||||
{
|
||||
def _weighted_disadvantaged(sub):
|
||||
needed = {"rwm_expected_disadvantaged_pct", "eligible_pupils"}
|
||||
if not needed <= set(sub.columns):
|
||||
return None
|
||||
s = sub.dropna(subset=list(needed))
|
||||
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, phase, with_disadvantaged):
|
||||
census = (census_benchmarks or {}).get(phase) or {}
|
||||
block = {
|
||||
"eal_pct": census.get("eal_pct"),
|
||||
"sen_support_pct": _median(sub, "sen_support_pct"),
|
||||
"disadvantaged_pct": _median(sub, "disadvantaged_pct") if with_disadvantaged else None,
|
||||
"fsm_pct": census.get("fsm_pct"),
|
||||
"median_pupils": census.get("median_pupils"),
|
||||
}
|
||||
if with_disadvantaged:
|
||||
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
|
||||
return block
|
||||
|
||||
return {
|
||||
"source": "state-school average (computed from our dataset)",
|
||||
"year": int(latest_year),
|
||||
"primary": _block(prim, "primary", with_disadvantaged=True),
|
||||
"secondary": _block(sec, "secondary", with_disadvantaged=False),
|
||||
}
|
||||
|
||||
|
||||
def _ofsted_block(o, urn: int) -> dict:
|
||||
"""Serialize the latest Ofsted inspection row for API responses.
|
||||
|
||||
`grade_source` records where the effective overall grade came from:
|
||||
a graded (Section 5) inspection, or carried forward from an ungraded
|
||||
(Section 8) outcome — materially different claims a UI must be able
|
||||
to distinguish. `report_card` holds coded+labelled renewed-framework
|
||||
(Nov 2025) area judgements; safeguarding is a separate boolean and
|
||||
never appears among the graded areas.
|
||||
"""
|
||||
if o.overall_effectiveness is not None:
|
||||
grade_source = "graded"
|
||||
overall = o.overall_effectiveness
|
||||
elif o.ungraded_grade is not None:
|
||||
# Fall back to the grade parsed from an ungraded (Section 8) outcome
|
||||
# (e.g. "School remains Good") so the detail page matches the list badge.
|
||||
grade_source = "ungraded_carried_forward"
|
||||
overall = o.ungraded_grade
|
||||
else:
|
||||
grade_source = None
|
||||
overall = None
|
||||
|
||||
block = {
|
||||
"framework": o.framework,
|
||||
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
|
||||
"inspection_type": o.inspection_type,
|
||||
# Fall back to the grade parsed from an ungraded (Section 8) outcome
|
||||
# (e.g. "School remains Good") when there's no graded grade, so the
|
||||
# detail page matches the list badge.
|
||||
"overall_effectiveness": (
|
||||
o.overall_effectiveness
|
||||
if o.overall_effectiveness is not None
|
||||
else o.ungraded_grade
|
||||
"rc_inspection_date": (
|
||||
o.rc_inspection_date.isoformat()
|
||||
if getattr(o, "rc_inspection_date", None)
|
||||
else None
|
||||
),
|
||||
"inspection_type": o.inspection_type,
|
||||
"overall_effectiveness": overall,
|
||||
"grade_source": grade_source,
|
||||
"quality_of_education": o.quality_of_education,
|
||||
"behaviour_attitudes": o.behaviour_attitudes,
|
||||
"personal_development": o.personal_development,
|
||||
@@ -505,28 +645,14 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
"rc_early_years": o.rc_early_years,
|
||||
"rc_sixth_form": o.rc_sixth_form,
|
||||
"report_url": o.report_url,
|
||||
"ofsted_page_url": ofsted_page_url(urn),
|
||||
}
|
||||
if o
|
||||
else None
|
||||
)
|
||||
block["report_card"] = report_card_labels(block)
|
||||
return block
|
||||
|
||||
# Census (latest year of fact_pupil_characteristics)
|
||||
pc = safe_query(FactPupilCharacteristics, "urn", "year")
|
||||
result["census"] = (
|
||||
{
|
||||
"year": pc.year,
|
||||
"total_pupils": pc.total_pupils,
|
||||
"female_pupils": pc.female_pupils,
|
||||
"male_pupils": pc.male_pupils,
|
||||
"fsm_pct": pc.fsm_pct,
|
||||
"eal_pct": pc.eal_pct,
|
||||
}
|
||||
if pc
|
||||
else None
|
||||
)
|
||||
|
||||
# Admissions — all years, oldest first (for the multi-year trend view).
|
||||
def _admissions_row(a):
|
||||
def _admissions_row_dict(a) -> dict:
|
||||
"""Serialize one fact_admissions row for API responses."""
|
||||
return {
|
||||
"year": a.year,
|
||||
"school_phase": a.school_phase,
|
||||
@@ -537,49 +663,35 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
"first_preference_offer_pct": a.first_preference_offer_pct,
|
||||
"oversubscription_ratio": a.oversubscription_ratio,
|
||||
"oversubscribed": a.oversubscribed,
|
||||
"total_offers": a.total_offers,
|
||||
"second_preference_offers": a.second_preference_offers,
|
||||
"third_preference_offers": a.third_preference_offers,
|
||||
"cross_la_applications": a.cross_la_applications,
|
||||
"cross_la_offers": a.cross_la_offers,
|
||||
}
|
||||
|
||||
try:
|
||||
admissions_rows = (
|
||||
db.query(FactAdmissions)
|
||||
.filter(FactAdmissions.urn == urn)
|
||||
.order_by(FactAdmissions.year.asc())
|
||||
.all()
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error("admissions history query failed: %s", e)
|
||||
db.rollback()
|
||||
admissions_rows = []
|
||||
|
||||
history = [_admissions_row(a) for a in admissions_rows]
|
||||
result["admissions_history"] = history
|
||||
# Keep the single latest-year object for backwards-compatible consumers
|
||||
# (hero chips, etc.).
|
||||
result["admissions"] = history[-1] if history else None
|
||||
def _census_dict(pc) -> dict:
|
||||
return {
|
||||
"year": pc.year,
|
||||
"total_pupils": pc.total_pupils,
|
||||
"female_pupils": pc.female_pupils,
|
||||
"male_pupils": pc.male_pupils,
|
||||
"fsm_pct": pc.fsm_pct,
|
||||
"eal_pct": pc.eal_pct,
|
||||
}
|
||||
|
||||
# SEN detail — not available in current marts
|
||||
result["sen_detail"] = None
|
||||
|
||||
# Phonics — no school-level data on EES
|
||||
result["phonics"] = None
|
||||
|
||||
# Deprivation
|
||||
d = safe_query(FactDeprivation, "urn")
|
||||
result["deprivation"] = (
|
||||
{
|
||||
def _deprivation_dict(d) -> dict:
|
||||
return {
|
||||
"lsoa_code": d.lsoa_code,
|
||||
"idaci_score": d.idaci_score,
|
||||
"idaci_decile": d.idaci_decile,
|
||||
}
|
||||
if d
|
||||
else None
|
||||
)
|
||||
|
||||
# Finance (latest year)
|
||||
f = safe_query(FactFinance, "urn", "year")
|
||||
result["finance"] = (
|
||||
{
|
||||
|
||||
def _finance_dict(f) -> dict:
|
||||
return {
|
||||
"year": f.year,
|
||||
"per_pupil_spend": f.per_pupil_spend,
|
||||
"staff_cost_pct": f.staff_cost_pct,
|
||||
@@ -587,8 +699,122 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
"support_staff_cost_pct": f.support_staff_cost_pct,
|
||||
"premises_cost_pct": f.premises_cost_pct,
|
||||
}
|
||||
if f
|
||||
else None
|
||||
|
||||
|
||||
def _empty_supplementary() -> dict:
|
||||
return {
|
||||
"ofsted": None,
|
||||
"census": None,
|
||||
"admissions": None,
|
||||
"admissions_history": [],
|
||||
"sen_detail": None,
|
||||
"phonics": None,
|
||||
"deprivation": None,
|
||||
"finance": None,
|
||||
}
|
||||
|
||||
|
||||
def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
|
||||
"""Fetch supplementary data for many URNs with one query per table
|
||||
(WHERE urn IN (...)) instead of ~5 queries per school, collapsing the
|
||||
per-request round-trips from 5*N to a constant 5. Returns {urn: block}
|
||||
with the same shape get_supplementary_data produces per URN.
|
||||
|
||||
Each table is queried independently and failures degrade that table to
|
||||
empty for every URN — a missing mart never blanks the others.
|
||||
"""
|
||||
urns = [int(u) for u in urns]
|
||||
result = {urn: _empty_supplementary() for urn in urns}
|
||||
if not urns:
|
||||
return result
|
||||
|
||||
def _safe(fn):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error("batch supplementary query failed: %s", e)
|
||||
db.rollback()
|
||||
|
||||
# Ofsted — latest inspection per URN. Ordered so the first row seen per
|
||||
# URN is the most recent.
|
||||
def _ofsted():
|
||||
rows = (
|
||||
db.query(FactOfstedInspection)
|
||||
.filter(FactOfstedInspection.urn.in_(urns))
|
||||
.order_by(FactOfstedInspection.urn, FactOfstedInspection.inspection_date.desc())
|
||||
.all()
|
||||
)
|
||||
seen = set()
|
||||
for o in rows:
|
||||
if o.urn in seen:
|
||||
continue
|
||||
seen.add(o.urn)
|
||||
result[o.urn]["ofsted"] = _ofsted_block(o, o.urn)
|
||||
_safe(_ofsted)
|
||||
|
||||
# Census — latest year per URN.
|
||||
def _census():
|
||||
rows = (
|
||||
db.query(FactPupilCharacteristics)
|
||||
.filter(FactPupilCharacteristics.urn.in_(urns))
|
||||
.order_by(FactPupilCharacteristics.urn, FactPupilCharacteristics.year.desc())
|
||||
.all()
|
||||
)
|
||||
seen = set()
|
||||
for pc in rows:
|
||||
if pc.urn in seen:
|
||||
continue
|
||||
seen.add(pc.urn)
|
||||
result[pc.urn]["census"] = _census_dict(pc)
|
||||
_safe(_census)
|
||||
|
||||
# Admissions — all years per URN, oldest first (multi-year trend view).
|
||||
def _admissions():
|
||||
rows = (
|
||||
db.query(FactAdmissions)
|
||||
.filter(FactAdmissions.urn.in_(urns))
|
||||
.order_by(FactAdmissions.urn, FactAdmissions.year.asc())
|
||||
.all()
|
||||
)
|
||||
history: dict = {urn: [] for urn in urns}
|
||||
for a in rows:
|
||||
history[a.urn].append(_admissions_row_dict(a))
|
||||
for urn, rows_for_urn in history.items():
|
||||
result[urn]["admissions_history"] = rows_for_urn
|
||||
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
||||
_safe(_admissions)
|
||||
|
||||
# Deprivation — one row per URN.
|
||||
def _deprivation():
|
||||
rows = (
|
||||
db.query(FactDeprivation)
|
||||
.filter(FactDeprivation.urn.in_(urns))
|
||||
.all()
|
||||
)
|
||||
for d in rows:
|
||||
result[d.urn]["deprivation"] = _deprivation_dict(d)
|
||||
_safe(_deprivation)
|
||||
|
||||
# Finance — latest year per URN.
|
||||
def _finance():
|
||||
rows = (
|
||||
db.query(FactFinance)
|
||||
.filter(FactFinance.urn.in_(urns))
|
||||
.order_by(FactFinance.urn, FactFinance.year.desc())
|
||||
.all()
|
||||
)
|
||||
seen = set()
|
||||
for f in rows:
|
||||
if f.urn in seen:
|
||||
continue
|
||||
seen.add(f.urn)
|
||||
result[f.urn]["finance"] = _finance_dict(f)
|
||||
_safe(_finance)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
"""Supplementary data for a single URN (thin wrapper over the batch)."""
|
||||
return get_supplementary_data_batch(db, [urn])[int(urn)]
|
||||
|
||||
@@ -78,6 +78,7 @@ OFFICIAL_SIXTH_FORM: dict[int, str] = {
|
||||
0: "Not applicable",
|
||||
1: "Has a sixth form",
|
||||
2: "Does not have a sixth form",
|
||||
9: "",
|
||||
}
|
||||
|
||||
RELIGIOUS_CHARACTER: dict[int, str] = {
|
||||
@@ -128,12 +129,14 @@ RELIGIOUS_CHARACTER: dict[int, str] = {
|
||||
47: "Reformed Baptist",
|
||||
48: "Roman Catholic/Anglican",
|
||||
49: "Sunni Deobandi",
|
||||
99: "",
|
||||
}
|
||||
|
||||
ADMISSIONS_POLICY: dict[int, str] = {
|
||||
0: "Not applicable",
|
||||
2: "Selective",
|
||||
4: "Non-selective",
|
||||
9: "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,15 @@ class KS2Performance(Base):
|
||||
maths_high_pct = Column(Float)
|
||||
maths_avg_score = Column(Float)
|
||||
maths_progress = Column(Float)
|
||||
# Progress confidence intervals + writing working-towards (published
|
||||
# for years with progress measures, i.e. up to 2022/23)
|
||||
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)
|
||||
gps_expected_pct = Column(Float)
|
||||
gps_high_pct = Column(Float)
|
||||
gps_avg_score = Column(Float)
|
||||
@@ -147,6 +156,9 @@ class FactOfstedInspection(Base):
|
||||
rc_leadership_governance = Column(Integer)
|
||||
rc_early_years = Column(Integer)
|
||||
rc_sixth_form = Column(Integer)
|
||||
# Start date of the report-card inspection itself (renewed framework,
|
||||
# Nov 2025+). Null for rows without report-card grades.
|
||||
rc_inspection_date = Column(Date)
|
||||
report_url = Column(Text)
|
||||
|
||||
|
||||
@@ -165,6 +177,11 @@ class FactAdmissions(Base):
|
||||
total_applications = Column(Integer)
|
||||
first_preference_applications = Column(Integer)
|
||||
first_preference_offers = Column(Integer)
|
||||
total_offers = Column(Integer)
|
||||
second_preference_offers = Column(Integer)
|
||||
third_preference_offers = Column(Integer)
|
||||
cross_la_applications = Column(Integer)
|
||||
cross_la_offers = Column(Integer)
|
||||
first_preference_offer_pct = Column(Float)
|
||||
oversubscription_ratio = Column(Float)
|
||||
oversubscribed = Column(Boolean)
|
||||
@@ -217,6 +234,42 @@ class FactFinance(Base):
|
||||
premises_cost_pct = Column(Float)
|
||||
|
||||
|
||||
class CensusBenchmark(Base):
|
||||
"""State-school context benchmarks from the pupil census — one row per phase.
|
||||
|
||||
fsm_pct / eal_pct are pupil-weighted means. Computed at import time;
|
||||
consumers label them "state-school average (computed from our dataset)".
|
||||
"""
|
||||
__tablename__ = "fact_census_benchmarks"
|
||||
__table_args__ = MARTS
|
||||
|
||||
phase = Column(String(20), primary_key=True)
|
||||
year = Column(Integer)
|
||||
fsm_pct = Column(Float)
|
||||
eal_pct = Column(Float)
|
||||
median_pupils = Column(Integer)
|
||||
|
||||
|
||||
class Ks4NationalAverage(Base):
|
||||
"""Official DfE KS4 national headline averages — one row per academic year.
|
||||
|
||||
gcse_grade_91_pct has no official national series and is always NULL.
|
||||
"""
|
||||
__tablename__ = "fact_ks4_national_averages"
|
||||
__table_args__ = MARTS
|
||||
|
||||
year = Column(Integer, primary_key=True)
|
||||
attainment_8_score = Column(Float)
|
||||
progress_8_score = Column(Float)
|
||||
english_maths_standard_pass_pct = Column(Float)
|
||||
english_maths_strong_pass_pct = Column(Float)
|
||||
ebacc_entry_pct = Column(Float)
|
||||
ebacc_standard_pass_pct = Column(Float)
|
||||
ebacc_strong_pass_pct = Column(Float)
|
||||
ebacc_avg_score = Column(Float)
|
||||
gcse_grade_91_pct = Column(Float)
|
||||
|
||||
|
||||
class Ks2NationalAverage(Base):
|
||||
"""Official DfE KS2 national headline averages — one row per academic year."""
|
||||
__tablename__ = "fact_ks2_national_averages"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""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)."""
|
||||
return f"https://reports.ofsted.gov.uk/provider/21/{urn}"
|
||||
@@ -0,0 +1,102 @@
|
||||
"""compute_benchmarks: state-school benchmarks computed from our dataset
|
||||
(spec §5/§8.6). The disadvantaged average must be weighted by cohort size,
|
||||
medians must ignore NaN, and only the latest year counts."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backend.data_loader import compute_benchmarks
|
||||
|
||||
LATEST = 202425
|
||||
|
||||
|
||||
def _df():
|
||||
rows = [
|
||||
# Six primary schools, latest year. Disadvantaged RWM chosen so the
|
||||
# weighted average differs clearly from the unweighted mean:
|
||||
# weighted = (40*100 + 60*300) / 400 = 55.0 ; unweighted mean = 50.0
|
||||
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=100,
|
||||
rwm_expected_disadvantaged_pct=40.0, eal_pct=10.0,
|
||||
sen_support_pct=10.0, disadvantaged_pct=20.0, fsm_pct=15.0, total_pupils=200),
|
||||
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=300,
|
||||
rwm_expected_disadvantaged_pct=60.0, eal_pct=20.0,
|
||||
sen_support_pct=14.0, disadvantaged_pct=24.0, fsm_pct=17.0, total_pupils=280),
|
||||
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=np.nan,
|
||||
rwm_expected_disadvantaged_pct=99.0, eal_pct=30.0,
|
||||
sen_support_pct=18.0, disadvantaged_pct=30.0, fsm_pct=19.0, total_pupils=300),
|
||||
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=50,
|
||||
rwm_expected_disadvantaged_pct=np.nan, eal_pct=np.nan,
|
||||
sen_support_pct=np.nan, disadvantaged_pct=np.nan, fsm_pct=np.nan, total_pupils=np.nan),
|
||||
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=40,
|
||||
rwm_expected_disadvantaged_pct=np.nan, eal_pct=40.0,
|
||||
sen_support_pct=20.0, disadvantaged_pct=40.0, fsm_pct=21.0, total_pupils=350),
|
||||
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=60,
|
||||
rwm_expected_disadvantaged_pct=np.nan, eal_pct=50.0,
|
||||
sen_support_pct=22.0, disadvantaged_pct=44.0, fsm_pct=23.0, total_pupils=400),
|
||||
# Two secondary schools (attainment_8 non-null)
|
||||
dict(year=LATEST, attainment_8_score=45.0, eligible_pupils=180,
|
||||
rwm_expected_disadvantaged_pct=np.nan, eal_pct=15.0,
|
||||
sen_support_pct=12.0, disadvantaged_pct=22.0, fsm_pct=12.0, total_pupils=1000),
|
||||
dict(year=LATEST, attainment_8_score=50.0, eligible_pupils=200,
|
||||
rwm_expected_disadvantaged_pct=np.nan, eal_pct=25.0,
|
||||
sen_support_pct=16.0, disadvantaged_pct=26.0, fsm_pct=14.0, total_pupils=1200),
|
||||
# An older-year primary row that must NOT influence anything
|
||||
dict(year=202324, attainment_8_score=np.nan, eligible_pupils=500,
|
||||
rwm_expected_disadvantaged_pct=1.0, eal_pct=99.0,
|
||||
sen_support_pct=99.0, disadvantaged_pct=99.0, fsm_pct=99.0, total_pupils=9999),
|
||||
]
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def test_weighted_disadvantaged_average():
|
||||
b = compute_benchmarks(_df())
|
||||
# Row 3 has NaN eligible_pupils and must be excluded from the weighting.
|
||||
assert b["primary"]["disadvantaged_rwm_expected_pct"] == 55.0
|
||||
|
||||
|
||||
def test_medians_ignore_nan_and_older_years():
|
||||
b = compute_benchmarks(_df())
|
||||
assert b["year"] == LATEST
|
||||
# sen medians over [10,14,18,20,22] = 18 — the only context measure still
|
||||
# sourced from the performance df (the rest come from the census mart).
|
||||
assert b["primary"]["sen_support_pct"] == 18.0
|
||||
# disadvantaged_pct medians over [20,24,30,40,44] = 30
|
||||
assert b["primary"]["disadvantaged_pct"] == 30.0
|
||||
|
||||
|
||||
def test_benchmarks_use_census_mart_for_context():
|
||||
census = {
|
||||
"primary": {"year": LATEST, "fsm_pct": 25.3, "eal_pct": 21.8, "median_pupils": 240},
|
||||
"secondary": {"year": LATEST, "fsm_pct": 24.1, "eal_pct": 18.9, "median_pupils": 980},
|
||||
}
|
||||
b = compute_benchmarks(_df(), census_benchmarks=census)
|
||||
assert b["primary"]["fsm_pct"] == 25.3
|
||||
assert b["primary"]["eal_pct"] == 21.8
|
||||
assert b["secondary"]["eal_pct"] == 18.9
|
||||
assert b["secondary"]["median_pupils"] == 980
|
||||
|
||||
|
||||
def test_benchmarks_context_none_when_mart_missing():
|
||||
# The performance df has no fsm_pct and its eal/disadvantaged columns are
|
||||
# KS2-only — never silently fall back to medianing them for context.
|
||||
b = compute_benchmarks(_df(), census_benchmarks=None)
|
||||
assert b["primary"]["fsm_pct"] is None
|
||||
assert b["primary"]["eal_pct"] is None
|
||||
assert b["primary"]["median_pupils"] is None
|
||||
|
||||
|
||||
def test_secondary_block_has_no_disadvantaged_rwm():
|
||||
b = compute_benchmarks(_df())
|
||||
assert "disadvantaged_rwm_expected_pct" not in b["secondary"]
|
||||
# KS2-only columns must not produce a fake secondary disadvantaged anchor
|
||||
# (the old median over all-through schools' KS2 rows produced 50%).
|
||||
assert b["secondary"]["disadvantaged_pct"] is None
|
||||
|
||||
|
||||
def test_provenance_string():
|
||||
b = compute_benchmarks(_df())
|
||||
assert b["source"] == "state-school average (computed from our dataset)"
|
||||
|
||||
|
||||
def test_empty_df():
|
||||
assert compute_benchmarks(pd.DataFrame()) == {}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""/api/compare enrichment for the compare redesign: per-school
|
||||
supplementary blocks, top-level national_averages (shared with the
|
||||
/api/national-averages endpoint) and computed benchmarks — all additive."""
|
||||
|
||||
import types
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
LATEST = 202425
|
||||
|
||||
CANNED_SUPPLEMENTARY = {
|
||||
"ofsted": {"overall_effectiveness": 2, "grade_source": "graded",
|
||||
"report_card": {}, "ofsted_page_url": "https://reports.ofsted.gov.uk/provider/21/100140"},
|
||||
"census": {"year": 202526, "fsm_pct": 29.8},
|
||||
"admissions": {"year": 202627, "second_preference_offers": 4},
|
||||
"admissions_history": [{"year": 202627, "second_preference_offers": 4}],
|
||||
"sen_detail": None,
|
||||
"phonics": None,
|
||||
"deprivation": {"idaci_decile": 4},
|
||||
"finance": None,
|
||||
}
|
||||
|
||||
|
||||
def _two_primary_schools_df() -> pd.DataFrame:
|
||||
rows = []
|
||||
for urn, name, rwm, dis in ((100140, "Plumcroft Primary School", 79.0, 72.0),
|
||||
(138690, "Barclay Primary School", 87.0, 86.0)):
|
||||
rows.append(dict(
|
||||
urn=urn, school_name=name, local_authority="Greenwich",
|
||||
school_type="Community school", address="1 Road", phase="Primary",
|
||||
year=LATEST, rwm_expected_pct=rwm, attainment_8_score=np.nan,
|
||||
eligible_pupils=60, rwm_expected_disadvantaged_pct=dis,
|
||||
eal_pct=20.0, sen_support_pct=14.0, disadvantaged_pct=25.0,
|
||||
total_pupils=1000.0,
|
||||
))
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
class _StubNatRow:
|
||||
year = 202425
|
||||
rwm_expected_pct = 62.1
|
||||
gps_expected_pct = 72.0
|
||||
science_expected_pct = 81.0
|
||||
|
||||
|
||||
class _StubSession:
|
||||
def query(self, *a, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *a, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return [_StubNatRow()]
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
from backend import app as app_module
|
||||
from backend import database as database_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _two_primary_schools_df)
|
||||
monkeypatch.setattr(
|
||||
app_module,
|
||||
"get_supplementary_data_batch",
|
||||
lambda db, urns: {int(u): dict(CANNED_SUPPLEMENTARY) for u in urns},
|
||||
)
|
||||
monkeypatch.setattr(database_module, "SessionLocal", _StubSession)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_existing_shape_is_preserved(client):
|
||||
body = client.get("/api/compare?urns=100140,138690").json()
|
||||
school = body["comparison"]["100140"]
|
||||
assert school["school_info"]["rwm_expected_pct"] == 79.0
|
||||
assert school["yearly_data"][0]["year"] == LATEST
|
||||
|
||||
|
||||
def test_each_school_gains_supplementary_blocks(client):
|
||||
body = client.get("/api/compare?urns=100140,138690").json()
|
||||
for urn in ("100140", "138690"):
|
||||
school = body["comparison"][urn]
|
||||
assert school["ofsted"]["grade_source"] == "graded"
|
||||
assert school["census"]["fsm_pct"] == 29.8
|
||||
assert school["admissions"]["second_preference_offers"] == 4
|
||||
assert school["admissions_history"][0]["year"] == 202627
|
||||
assert school["deprivation"]["idaci_decile"] == 4
|
||||
|
||||
|
||||
def test_top_level_national_averages_and_benchmarks(client):
|
||||
body = client.get("/api/compare?urns=100140,138690").json()
|
||||
assert body["national_averages"]["year"] == LATEST
|
||||
assert body["benchmarks"]["source"] == "state-school average (computed from our dataset)"
|
||||
# weighted over equal cohorts of 72 and 86 = 79.0
|
||||
assert body["benchmarks"]["primary"]["disadvantaged_rwm_expected_pct"] == 79.0
|
||||
|
||||
|
||||
def test_supplementary_failure_degrades_not_500(client, monkeypatch):
|
||||
from backend import app as app_module
|
||||
|
||||
def _boom(db, urns):
|
||||
raise RuntimeError("marts unavailable")
|
||||
|
||||
monkeypatch.setattr(app_module, "get_supplementary_data_batch", _boom)
|
||||
resp = client.get("/api/compare?urns=100140")
|
||||
assert resp.status_code == 200
|
||||
school = resp.json()["comparison"]["100140"]
|
||||
assert school["ofsted"] is None
|
||||
assert school["admissions_history"] == []
|
||||
|
||||
|
||||
def test_national_averages_endpoint_exposes_gps_science(client):
|
||||
body = client.get("/api/national-averages").json()
|
||||
latest_primary_by_year = [e["primary"] for e in body["by_year"] if e["primary"]]
|
||||
assert latest_primary_by_year, "expected official by_year rows from the stub"
|
||||
assert latest_primary_by_year[-1]["gps_expected_pct"] == 72.0
|
||||
assert latest_primary_by_year[-1]["science_expected_pct"] == 81.0
|
||||
@@ -81,3 +81,15 @@ def test_seed_matches_dictionaries():
|
||||
for row in csv.DictReader(fh):
|
||||
seed[row["field"]][int(row["code"])] = row["name"]
|
||||
assert seed == fields
|
||||
|
||||
|
||||
def test_blank_name_sentinel_codes_map_to_empty_string():
|
||||
"""GIAS carries codes whose (name) column is blank — e.g. ReligiousCharacter
|
||||
99 (~4k schools) and AdmissionsPolicy 9 (~5.6k schools). The old name
|
||||
pipeline served these as empty strings; the dictionaries must reproduce
|
||||
that ("" is falsy, so UI tag heuristics stay silent) rather than letting
|
||||
them hit the "Unknown (<code>)" path meant for genuinely new codes."""
|
||||
assert RELIGIOUS_CHARACTER[99] == ""
|
||||
assert ADMISSIONS_POLICY[9] == ""
|
||||
assert translate(99, RELIGIOUS_CHARACTER) == ""
|
||||
assert translate(9, ADMISSIONS_POLICY) == ""
|
||||
|
||||
@@ -4,7 +4,7 @@ rest of the backend sees must carry today's name strings."""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backend.data_loader import translate_gias_code_columns
|
||||
from backend.data_loader import _missing_column_name, translate_gias_code_columns
|
||||
from backend.gias_codes import ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION
|
||||
|
||||
|
||||
@@ -42,3 +42,91 @@ def test_missing_code_columns_are_a_noop():
|
||||
out = translate_gias_code_columns(df)
|
||||
assert out.iloc[0]["phase"] == "Primary"
|
||||
assert out.iloc[0]["status"] == "Open"
|
||||
|
||||
|
||||
def _fake_exc(orig_message):
|
||||
"""A stand-in for sqlalchemy.exc.ProgrammingError: str(exc) embeds the
|
||||
full SQL statement (deliberately containing every column name below, to
|
||||
prove the matcher doesn't fall back to it), while .orig carries the real
|
||||
DBAPI error message naming only the offending column."""
|
||||
exc = Exception(
|
||||
"SELECT s.phase_code, s.school_type_code, s.religious_character_code, "
|
||||
"s.status_code, s.admissions_policy_code, s.has_sixth_form FROM ... "
|
||||
f"[SQL: ...] (Background on this error at: https://...)"
|
||||
)
|
||||
exc.orig = Exception(orig_message) if orig_message is not None else None
|
||||
return exc
|
||||
|
||||
|
||||
def test_missing_column_name_quoted():
|
||||
assert _missing_column_name(_fake_exc('column "phase_code" does not exist')) == "phase_code"
|
||||
|
||||
|
||||
def test_missing_column_name_unquoted():
|
||||
assert _missing_column_name(_fake_exc("column phase_code does not exist")) == "phase_code"
|
||||
|
||||
|
||||
def test_missing_column_name_table_prefixed():
|
||||
assert (
|
||||
_missing_column_name(_fake_exc("column s.has_sixth_form does not exist"))
|
||||
== "has_sixth_form"
|
||||
)
|
||||
|
||||
|
||||
def test_missing_column_name_no_match_returns_none():
|
||||
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
|
||||
|
||||
|
||||
def test_load_school_data_survives_premigration_marts(monkeypatch):
|
||||
"""Real prod state until the nightly pipeline first rebuilds the mart with
|
||||
the GIAS code columns: marts.dim_school still has the old name columns
|
||||
(phase, school_type, religious_character, status, admissions_policy)
|
||||
instead of the new *_code columns. The first query raises UndefinedColumn
|
||||
on s.phase_code; load_school_data_as_dataframe must retry with the
|
||||
legacy name-column query rather than swallow the error and return (and
|
||||
then have load_school_data cache) an empty DataFrame."""
|
||||
import sqlalchemy.exc
|
||||
from backend import data_loader
|
||||
|
||||
data_loader._df_cache = None
|
||||
data_loader._df_latest_cache = None
|
||||
|
||||
good_df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"urn": 1,
|
||||
"school_name": "Legacy School",
|
||||
"phase": "Primary",
|
||||
"school_type": "Academy",
|
||||
"status": "Open",
|
||||
}
|
||||
]
|
||||
)
|
||||
calls = []
|
||||
|
||||
def fake_read_sql(query, con):
|
||||
calls.append(query)
|
||||
if len(calls) == 1:
|
||||
raise sqlalchemy.exc.ProgrammingError(
|
||||
statement=str(data_loader._MAIN_QUERY),
|
||||
params=None,
|
||||
orig=Exception(
|
||||
"(psycopg2.errors.UndefinedColumn) column s.phase_code "
|
||||
"does not exist\nLINE 5: s.phase_code,"
|
||||
),
|
||||
)
|
||||
return good_df.copy()
|
||||
|
||||
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
|
||||
|
||||
try:
|
||||
df = data_loader.load_school_data_as_dataframe()
|
||||
finally:
|
||||
data_loader._df_cache = None
|
||||
data_loader._df_latest_cache = None
|
||||
|
||||
assert len(calls) == 2, "must retry with the legacy name-column query variant"
|
||||
assert calls[1] is data_loader._MAIN_QUERY_LEGACY_NAMES
|
||||
assert not df.empty
|
||||
assert df["phase"].iloc[0] == "Primary"
|
||||
assert df["status"].iloc[0] == "Open"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""_national_averages_payload reads persisted marts (computed at import
|
||||
time) — it must never aggregate the dataframe. Both marts hold OFFICIAL
|
||||
DfE figures, so a missing KS4 mart yields an empty secondary series —
|
||||
never a computed stand-in the UI would mislabel as official."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
LATEST = 202425
|
||||
|
||||
|
||||
def _df():
|
||||
return pd.DataFrame(
|
||||
[
|
||||
dict(year=202324, attainment_8_score=40.0, rwm_expected_pct=np.nan),
|
||||
dict(year=LATEST, attainment_8_score=50.0, rwm_expected_pct=np.nan),
|
||||
dict(year=LATEST, attainment_8_score=30.0, rwm_expected_pct=np.nan),
|
||||
dict(year=LATEST, attainment_8_score=np.nan, rwm_expected_pct=80.0),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _Ks2Row:
|
||||
year = LATEST
|
||||
rwm_expected_pct = 62.1
|
||||
gps_expected_pct = 72.0
|
||||
|
||||
|
||||
class _Ks4Row:
|
||||
year = LATEST
|
||||
attainment_8_score = 46.5
|
||||
progress_8_score = -0.02
|
||||
|
||||
|
||||
class _StubSession:
|
||||
"""Returns KS2 rows for the first query and KS4 rows for the second —
|
||||
mirroring the payload's query order."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def query(self, model):
|
||||
self._model = model.__name__
|
||||
return self
|
||||
|
||||
def order_by(self, *a):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return [_Ks2Row()] if self._model == "Ks2NationalAverage" else [_Ks4Row()]
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Ks4MissingSession(_StubSession):
|
||||
def all(self):
|
||||
if self._model == "Ks4NationalAverage":
|
||||
raise RuntimeError("relation does not exist")
|
||||
return [_Ks2Row()]
|
||||
|
||||
def rollback(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def payload(monkeypatch):
|
||||
from backend import app as app_module
|
||||
from backend import database as database_module
|
||||
|
||||
def _run(session_cls):
|
||||
monkeypatch.setattr(database_module, "SessionLocal", session_cls)
|
||||
return app_module._national_averages_payload(_df())
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
|
||||
body = payload(_StubSession)
|
||||
# Mart value (46.5), NOT the dataframe mean of (50+30)/2 = 40.0
|
||||
assert body["secondary"]["attainment_8_score"] == 46.5
|
||||
assert body["primary"]["rwm_expected_pct"] == 62.1
|
||||
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
|
||||
|
||||
|
||||
def test_ks4_secondary_empty_when_mart_missing(payload):
|
||||
# No computed stand-in: the UI labels national figures as official DfE
|
||||
# data, so an empty mart must yield an empty secondary series.
|
||||
body = payload(_Ks4MissingSession)
|
||||
assert body["secondary"] == {}
|
||||
assert all(not e["secondary"] for e in body["by_year"])
|
||||
# The KS2 series is unaffected.
|
||||
assert body["primary"]["rwm_expected_pct"] == 62.1
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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,
|
||||
ofsted_page_url,
|
||||
report_card_labels,
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
@@ -148,10 +148,14 @@ def test_load_school_data_survives_missing_has_sixth_form_column(monkeypatch):
|
||||
def fake_read_sql(query, con):
|
||||
calls.append(query)
|
||||
if len(calls) == 1:
|
||||
# The statement text still contains phase_code, school_type_code,
|
||||
# etc. (it's the full _MAIN_QUERY SELECT list) — that's exactly
|
||||
# the collision this test guards against: matching must be done
|
||||
# against exc.orig (the DBAPI error), not str(exc)/the statement.
|
||||
raise sqlalchemy.exc.ProgrammingError(
|
||||
"SELECT ...",
|
||||
None,
|
||||
Exception(
|
||||
statement=str(data_loader._MAIN_QUERY),
|
||||
params=None,
|
||||
orig=Exception(
|
||||
"(psycopg2.errors.UndefinedColumn) column s.has_sixth_form "
|
||||
"does not exist"
|
||||
),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""get_supplementary_data_batch fetches one query per table for all URNs
|
||||
(not ~5 per school) and returns the same per-URN block shape as the
|
||||
single-URN function, picking the latest row per URN where relevant."""
|
||||
|
||||
import types
|
||||
|
||||
from backend import data_loader
|
||||
from backend.data_loader import get_supplementary_data_batch
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
"""Records that a query ran and serves canned rows filtered by an in-list."""
|
||||
|
||||
def __init__(self, recorder, model_name, rows):
|
||||
self._rec = recorder
|
||||
self._model = model_name
|
||||
self._rows = rows
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
self._rec.append(self._model)
|
||||
return self._rows
|
||||
|
||||
def first(self):
|
||||
self._rec.append(self._model)
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, rows_by_model):
|
||||
self.rows_by_model = rows_by_model
|
||||
self.queries: list[str] = []
|
||||
|
||||
def query(self, model):
|
||||
name = model.__name__
|
||||
return _FakeQuery(self.queries, name, self.rows_by_model.get(name, []))
|
||||
|
||||
def rollback(self):
|
||||
pass
|
||||
|
||||
|
||||
def _ofsted_row(urn, date, oe):
|
||||
base = {f: None for f in (
|
||||
"framework", "inspection_type", "quality_of_education", "behaviour_attitudes",
|
||||
"personal_development", "leadership_management", "early_years_provision",
|
||||
"sixth_form_provision", "ungraded_outcome", "ungraded_grade",
|
||||
"rc_safeguarding_met", "rc_inclusion", "rc_curriculum_teaching", "rc_achievement",
|
||||
"rc_attendance_behaviour", "rc_personal_development", "rc_leadership_governance",
|
||||
"rc_early_years", "rc_sixth_form", "report_url",
|
||||
)}
|
||||
base.update(urn=urn, inspection_date=types.SimpleNamespace(isoformat=lambda: date),
|
||||
overall_effectiveness=oe, grade_source=None)
|
||||
return types.SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _adm_row(urn, year):
|
||||
return types.SimpleNamespace(
|
||||
urn=urn, year=year, school_phase="Primary", places_offered=100,
|
||||
total_applications=200, first_preference_applications=150,
|
||||
first_preference_offers=140, first_preference_offer_pct=93.3,
|
||||
oversubscription_ratio=1.5, oversubscribed=True,
|
||||
total_offers=100, second_preference_offers=5, third_preference_offers=2,
|
||||
cross_la_applications=10, cross_la_offers=3,
|
||||
)
|
||||
|
||||
|
||||
def test_one_query_per_table_and_latest_row_per_urn():
|
||||
rows = {
|
||||
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
||||
"FactOfstedInspection": [
|
||||
_ofsted_row(1, "2023-01-01", 2),
|
||||
_ofsted_row(1, "2019-01-01", 3),
|
||||
_ofsted_row(2, "2021-06-01", 1),
|
||||
],
|
||||
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
||||
"FactPupilCharacteristics": [],
|
||||
"FactDeprivation": [],
|
||||
"FactFinance": [],
|
||||
}
|
||||
session = _FakeSession(rows)
|
||||
out = get_supplementary_data_batch(session, [1, 2])
|
||||
|
||||
# Exactly one query per table — five total, regardless of two URNs.
|
||||
assert sorted(session.queries) == [
|
||||
"FactAdmissions", "FactDeprivation", "FactFinance",
|
||||
"FactOfstedInspection", "FactPupilCharacteristics",
|
||||
]
|
||||
|
||||
# Latest Ofsted kept per URN
|
||||
assert out[1]["ofsted"]["overall_effectiveness"] == 2
|
||||
assert out[2]["ofsted"]["overall_effectiveness"] == 1
|
||||
|
||||
# Admissions history grouped per URN, latest exposed as `admissions`
|
||||
assert [r["year"] for r in out[1]["admissions_history"]] == [202526, 202627]
|
||||
assert out[1]["admissions"]["year"] == 202627
|
||||
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
||||
|
||||
# Empty tables degrade to the null block, not a crash
|
||||
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
||||
|
||||
|
||||
def test_single_wrapper_matches_batch(monkeypatch):
|
||||
session = _FakeSession({"FactOfstedInspection": [_ofsted_row(5, "2022-01-01", 2)]})
|
||||
single = data_loader.get_supplementary_data(session, 5)
|
||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||
assert single["admissions_history"] == []
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Supplementary-block enrichment for the compare redesign: report-card
|
||||
labels, provider-page URL, graded-vs-carried-forward provenance, and the
|
||||
admissions preference/cross-LA detail promoted in the data-foundation PR."""
|
||||
|
||||
import types
|
||||
from datetime import date
|
||||
|
||||
from backend.data_loader import _admissions_row_dict, _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_ofsted_block_carries_rc_inspection_date():
|
||||
o = _row(
|
||||
ungraded_grade=2,
|
||||
rc_achievement=1,
|
||||
rc_inspection_date=date(2026, 2, 3),
|
||||
inspection_date=date(2021, 10, 7),
|
||||
)
|
||||
block = _ofsted_block(o, urn=138690)
|
||||
assert block["rc_inspection_date"] == "2026-02-03"
|
||||
# The legacy inspection date is still present, unchanged.
|
||||
assert block["inspection_date"] == "2021-10-07"
|
||||
|
||||
|
||||
def test_ofsted_block_rc_inspection_date_none_when_absent():
|
||||
o = _row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
|
||||
block = _ofsted_block(o, urn=136276)
|
||||
assert block["rc_inspection_date"] is None
|
||||
|
||||
|
||||
def test_ofsted_block_keeps_existing_keys():
|
||||
block = _ofsted_block(_row(overall_effectiveness=2, quality_of_education=2), urn=1)
|
||||
for key in ("framework", "inspection_date", "overall_effectiveness",
|
||||
"quality_of_education", "rc_inclusion", "report_url"):
|
||||
assert key in block
|
||||
|
||||
|
||||
def test_admissions_row_new_fields():
|
||||
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)
|
||||
# Existing keys unchanged
|
||||
assert d["first_preference_offer_pct"] == 100.0
|
||||
assert d["oversubscribed"] is False
|
||||
@@ -112,11 +112,15 @@ Full details in `docs/DEPLOY.md`. The short version:
|
||||
- **Never push to `main` directly.** Work on a feature branch and open a PR;
|
||||
branch protection requires the PR checks (typecheck, tests, builds, AI review)
|
||||
to pass before merge.
|
||||
- Merging to `main` deploys automatically: images are built once, deployed to
|
||||
the **staging** Portainer stack, verified by the Playwright journeys in
|
||||
`e2e/`, and only then retagged `:prod` and rolled out to production.
|
||||
- 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. Never trigger it yourself — promotion is the human's call.
|
||||
- If you change user-facing behaviour, update or extend the `e2e/` journey
|
||||
tests in the same PR — they are the promotion gate.
|
||||
tests in the same PR — they gate whether staging is fit for human testing
|
||||
and whether a commit is promotable.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
|
||||
+36
-15
@@ -1,41 +1,61 @@
|
||||
# SDLC & Deployment Pipeline
|
||||
|
||||
SchoolCompare uses a fully automated staging → production pipeline on Gitea
|
||||
Actions. AI writes the code on feature branches; the pipeline verifies every
|
||||
change on a staging environment before promoting the exact same images to
|
||||
production. Human input is directional only: feature requests, PR review if
|
||||
desired, and intervention when a gate fails.
|
||||
SchoolCompare uses a two-stage deploy model on Gitea Actions with two human
|
||||
approvals. AI writes the code on feature branches; the first approval merges
|
||||
the PR, which deploys to staging and runs the E2E gate; the second approval —
|
||||
after manual testing on staging — promotes the exact same images to
|
||||
production via a manual workflow.
|
||||
|
||||
## The flow
|
||||
|
||||
```
|
||||
feature branch (AI-authored)
|
||||
│ PR to main
|
||||
│ PR to main ← approval #1
|
||||
▼
|
||||
PR checks (.gitea/workflows/pr-checks.yml)
|
||||
typecheck + unit tests + backend smoke + image builds (no push)
|
||||
+ Claude code review posted as a PR comment (severe findings fail the check)
|
||||
│ merge (branch protection requires green checks)
|
||||
▼
|
||||
Deploy pipeline (.gitea/workflows/deploy.yml)
|
||||
Stage pipeline (.gitea/workflows/deploy.yml) — automatic
|
||||
1. build & push images → tags sha-<sha>, staging
|
||||
2. staging Portainer webhook → wait for staging health
|
||||
3. Playwright E2E journeys against staging
|
||||
4. retag sha-<sha> → :prod (same bytes — build once, promote the image)
|
||||
3. Playwright E2E journeys against staging ← gate before human testing
|
||||
▼
|
||||
Manual testing on staging (stx.schoolcompare.co.uk)
|
||||
│ Actions → "Promote to Production (manual)" ← approval #2
|
||||
▼
|
||||
Promote pipeline (.gitea/workflows/promote.yml) — manual dispatch
|
||||
1. resolve target sha (input, or latest main if empty)
|
||||
2. REFUSE unless that commit's "E2E Journeys against Staging" status is green
|
||||
3. retag sha-<sha> → :prod (same bytes — build once, promote the image)
|
||||
previous :prod saved as :prod-previous
|
||||
5. prod Portainer webhook → wait for prod health
|
||||
4. prod Portainer webhook → wait for prod health
|
||||
```
|
||||
|
||||
Key principle: **build once, promote the exact image**. Production pins `:prod`,
|
||||
which only moves after the E2E gate passes on staging. Nothing tags `:latest`
|
||||
which only moves when a human runs the promote workflow — and the workflow
|
||||
only accepts commits that passed the staging E2E gate. Nothing tags `:latest`
|
||||
anymore.
|
||||
|
||||
## Branch & PR workflow
|
||||
|
||||
- `main` is protected: no direct pushes, PRs require green status checks.
|
||||
- All work (human or AI) happens on feature branches → PR to `main`.
|
||||
- Merging to `main` **is** the release action. If staging or the E2E gate
|
||||
fails, production is untouched.
|
||||
- Merging to `main` releases **to staging only**. Production moves only on
|
||||
the second approval. If staging or the E2E gate fails, fix forward —
|
||||
production is untouched either way.
|
||||
|
||||
## 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. To promote an older state, pass its
|
||||
commit SHA to the promote workflow (its images must still exist in the
|
||||
registry).
|
||||
|
||||
Staging quirk for manual testing: external `/api` is broken at the staging
|
||||
proxy — exercise API endpoints from the host, not via the public staging URL.
|
||||
|
||||
## Environments
|
||||
|
||||
@@ -92,8 +112,9 @@ fail the E2E gate. That's the point: staging absorbs the risk.
|
||||
|
||||
## Rollback
|
||||
|
||||
Every promotion first re-points `:prod-previous` at the outgoing `:prod`.
|
||||
To roll back:
|
||||
Re-run "Promote to Production (manual)" with the SHA of the last good commit
|
||||
(fastest, fully gated), or manually re-point the tags — every promotion first
|
||||
saves the outgoing `:prod` as `:prod-previous`:
|
||||
|
||||
```bash
|
||||
for img in backend frontend pipeline; do
|
||||
|
||||
@@ -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,931 @@
|
||||
# Compare Screen Must-Fix (Final Expert Review) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix the five promotion-blocking findings from the expert's final staging review: (1) blank all-secondary compare view, (2) report cards dated with pre-Nov-2025 legacy inspection dates, (3) FSM chip benchmarked against the wrong measure, (4) KS4 "national averages" that are dataset means presented as official DfE figures, (5) factually wrong "DfE didn't publish 2021/22" footnote.
|
||||
|
||||
**Architecture:** One branch/PR touching all three layers. Pipeline: a new tap field carries the report-card inspection's own date; a new EES stream ingests official KS4 national headlines; a new census-benchmarks mart replaces junk KS2-derived context medians. Backend: serialize the new fields, stop mislabelling computed KS4 means as official. Frontend: fix the phase-detection effect that leaves all-secondary comparisons stuck on an empty "primary" tab, date report cards correctly, drop the FSM→disadvantaged fallback, fix the footnote copy.
|
||||
|
||||
**Tech Stack:** Meltano/Singer taps (Python), dbt-postgres, FastAPI/SQLAlchemy/pandas, Next.js app router + Jest, Playwright e2e.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Never push to `main`; work on branch `fix/compare-final-review-mustfix`, open a PR. Never trigger the "Promote to Production (manual)" workflow — promotion is exclusively the human's call.
|
||||
- User-facing behaviour changes must extend the `e2e/` journeys in the same PR (they gate staging fitness and promotability).
|
||||
- All user-facing copy on the compare screen comes verbatim from `docs/superpowers/specs/mockups/compare-desktop.html` / `compare-mobile.html` — except where this plan explicitly changes copy to fix a factual error (Task 6); the spec/mockup gets the same wording in the same commit.
|
||||
- Benchmark provenance house style: official figures = "England average"; computed figures = "state-school average (computed from our dataset)".
|
||||
- A report card must NEVER be displayed with a pre-November-2025 date. Report cards exist only from November 2025.
|
||||
- Backend tests: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q` (repo root; there is no local pytest).
|
||||
- dbt: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` (never bare `dbt` — the Fusion binary shadows dbt-postgres).
|
||||
- Frontend: `cd nextjs-app && npx tsc --noEmit && npm test` (run tsc un-piped so exit codes are not masked).
|
||||
- Do NOT start a local server to test the application (CLAUDE.md).
|
||||
- Commits end with the Claude Code `Co-Authored-By` + `Claude-Session` trailers used on this branch's history.
|
||||
|
||||
## Root-Cause Evidence (verified 2026-07-16, do not re-derive)
|
||||
|
||||
- **Finding 1:** `nextjs-app/components/ComparisonView.tsx:164-176` — the auto-phase effect returns early when `selectedSchools.length === 0` (basket hydrates a beat after mount) and its dep array is only `[comparisonData]`, so it never re-fires; `comparePhase` stays `'primary'`, `activeSchools` is empty, the page renders "No primary schools in your comparison" (a11y snapshot confirmed). No console errors — not a crash.
|
||||
- **Finding 2:** In the Ofsted MI CSV (`Management_information_-_state-funded_schools_-_latest_inspections_as_at_31_May_2026.csv`) the report-card grade columns (cols 38–55, "Safeguarding standards", "Inclusion", …) belong to the **latest full inspection** block whose date is col 30 "Inspection start date" (Barclay 138690: `03/02/2026`). The tap's `inspection_date` COLUMN_PRIORITY matches col 60 "Inspection start date of latest OEIF graded inspection" first (the *legacy* date; NULL for Barclay, so stg coalesces to the 2021 *ungraded* date). The rc data is **real Ofsted data, not fabricated** — it is mis-dated. Also `discover_csv_url()` returns `matches[0]` = the oldest (2017) link on the GOV.UK page; staging works only because `mi_url` is set in the environment. Staging raw is stale for at least Watford Grammar 136276 (staging shows rc grades; the current MI file has all rc columns NULL for it) — a fresh extract fixes that via upsert on `(urn, inspection_date)`.
|
||||
- **Finding 3:** `nextjs-app/components/compare/CompareCommunity.tsx:36` — `bench?.fsm_pct ?? bench?.disadvantaged_pct` falls back across definitions. `benchmarks.primary.fsm_pct` is null because `compute_benchmarks` (backend/data_loader.py:528) medians the *performance* df, which has no `fsm_pct` (school FSM comes from `census.fsm_pct` = `fact_pupil_characteristics`). `disadvantaged_pct` / `eal_pct` are KS2-only columns, so the "secondary" medians (50.0 / 10.0) are computed over the few all-through schools' KS2 rows — junk.
|
||||
- **Finding 4:** `fact_ks4_national_averages.sql` computes unweighted school means (A8 38.94 vs official 46.0; national P8 −0.27, impossible). Official series exists on EES: data-set `1b649e16-01e8-435b-a814-56be2faf9054` ("National characteristics summary data", KS4 performance publication), CSV endpoint same pattern as the KS2 national stream, national level, 2018/19→2024/25, `establishment_type_group = 'All state-funded'`, `breakdown_topic = 'Total'`, `breakdown = 'Total'`. Verified values: 2024/25 A8 46.0, P8 `z` (not published — no KS2 baseline for that cohort), EM 9-5 45.4%, EBacc entry 40.5%. It has **no** `gcse_91_percent` column.
|
||||
- **Finding 5:** `ComparisonChart.tsx:245-246` claims "DfE didn't publish school-level figures for 2021/22". False — DfE published school-level KS2 for 2021/22 in Dec 2022; spec §8.1 itself lists loading it as a pipeline task. The honest claim is that the figures aren't in our dataset.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: All-secondary comparison renders (phase-detection fix)
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/components/ComparisonView.tsx:176`
|
||||
- Create: `nextjs-app/__tests__/components/ComparisonView.phase.test.tsx`
|
||||
- Modify: `e2e/tests/journeys.spec.ts` (add helper + journey after the existing `twoPrimaryUrns` helper / primary compare journey)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `ComparisonView` props (`initialData`, `initialUrns`, `metrics`, `selectedMetric`), `ComparisonProvider`.
|
||||
- Produces: no API changes; the auto-phase effect re-runs when the basket hydrates.
|
||||
|
||||
- [ ] **Step 1: Write the failing Jest test**
|
||||
|
||||
Create `nextjs-app/__tests__/components/ComparisonView.phase.test.tsx` (mirrors the mock setup of `ComparisonView.refresh.test.tsx`):
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* Regression: an all-secondary comparison must render the secondary sections.
|
||||
*
|
||||
* The basket hydrates from the URL a beat after mount, so the auto-phase
|
||||
* effect must re-run once selectedSchools arrives — with deps of only
|
||||
* [comparisonData] it fired once against an empty basket, bailed, and the
|
||||
* page stayed on an empty "primary" tab ("No primary schools in your
|
||||
* comparison") even though all schools were secondary.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { ComparisonView } from '@/components/ComparisonView';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import type { ComparisonData, School } from '@/lib/types';
|
||||
|
||||
const fetchComparison = jest.fn();
|
||||
jest.mock('@/lib/api', () => ({
|
||||
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||
}));
|
||||
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||
|
||||
function secondarySchool(urn: number, name: string): School {
|
||||
return {
|
||||
urn,
|
||||
school_name: name,
|
||||
local_authority: 'Testshire',
|
||||
school_type: 'Academy converter',
|
||||
attainment_8_score: 55,
|
||||
phase: 'Secondary',
|
||||
} as School;
|
||||
}
|
||||
|
||||
function data(urn: number, name: string): ComparisonData {
|
||||
return {
|
||||
school_info: secondarySchool(urn, name),
|
||||
yearly_data: [{ year: 202425, attainment_8_score: 55 }] as ComparisonData['yearly_data'],
|
||||
ofsted: null,
|
||||
census: null,
|
||||
admissions: null,
|
||||
admissions_history: [],
|
||||
deprivation: null,
|
||||
};
|
||||
}
|
||||
|
||||
const INITIAL_DATA = {
|
||||
'300': data(300, 'Gamma High'),
|
||||
'400': data(400, 'Delta Academy'),
|
||||
};
|
||||
|
||||
test('an all-secondary comparison renders the sections, not an empty primary tab', async () => {
|
||||
render(
|
||||
<ComparisonProvider>
|
||||
<ComparisonView
|
||||
initialData={INITIAL_DATA}
|
||||
initialNationalAverages={{
|
||||
year: 202425,
|
||||
primary: {},
|
||||
secondary: { attainment_8_score: 46 },
|
||||
by_year: [],
|
||||
}}
|
||||
initialBenchmarks={undefined}
|
||||
initialUrns={[300, 400]}
|
||||
metrics={[]}
|
||||
selectedMetric="attainment_8_score"
|
||||
/>
|
||||
</ComparisonProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
|
||||
expect(fetchComparison).not.toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails**
|
||||
|
||||
Run: `cd nextjs-app && npx jest __tests__/components/ComparisonView.phase.test.tsx`
|
||||
Expected: FAIL — "No primary schools in your comparison" is rendered / "At a glance" never appears.
|
||||
|
||||
- [ ] **Step 3: Fix the effect dependencies**
|
||||
|
||||
In `nextjs-app/components/ComparisonView.tsx`, the auto-phase effect currently ends:
|
||||
|
||||
```tsx
|
||||
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```tsx
|
||||
// selectedSchools is a dep because the basket hydrates after mount: the
|
||||
// first run sees an empty basket and bails, so it must re-fire when the
|
||||
// schools arrive. primarySchools/secondarySchools/metrics/selectedMetric
|
||||
// are intentionally omitted (derived or would cause loops).
|
||||
}, [comparisonData, selectedSchools]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
```
|
||||
|
||||
(`phaseLockedByUser` still suppresses re-detection after a manual tab click; re-running with unchanged inputs sets the same state, which React treats as a no-op.)
|
||||
|
||||
- [ ] **Step 4: Run the new test and the existing suite**
|
||||
|
||||
Run: `cd nextjs-app && npx tsc --noEmit && npm test`
|
||||
Expected: PASS, including `ComparisonView.refresh.test.tsx` (the refresh regression must stay green).
|
||||
|
||||
- [ ] **Step 5: Add the e2e secondary journey**
|
||||
|
||||
In `e2e/tests/journeys.spec.ts`, add below `twoPrimaryUrns`:
|
||||
|
||||
```ts
|
||||
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
|
||||
const res = await page.request.get('/api/schools?search=school&per_page=100');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
const urns: string[] = (body.schools ?? [])
|
||||
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
|
||||
s.phase === 'Secondary' && s.attainment_8_score != null,
|
||||
)
|
||||
.map((s: { urn: number }) => String(s.urn));
|
||||
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||
return [urns[0], urns[1]];
|
||||
}
|
||||
```
|
||||
|
||||
(If `/api/schools` list rows lack `attainment_8_score`, filter on `s.phase === 'Secondary'` only — check the response first.) Then add a journey test next to the primary compare journey:
|
||||
|
||||
```ts
|
||||
test('comparing two secondary schools renders the secondary sections', async ({ page }) => {
|
||||
const [urn0, urn1] = await twoSecondaryUrns(page);
|
||||
|
||||
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The parent-first sections must render — this page was completely blank
|
||||
// for all-secondary baskets (expert review must-fix #1).
|
||||
await expect(page.getByRole('heading', { name: 'At a glance' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Ofsted inspection' })).toBeVisible();
|
||||
// A KS4 measure proves the secondary academics variant rendered.
|
||||
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
||||
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/components/ComparisonView.tsx nextjs-app/__tests__/components/ComparisonView.phase.test.tsx e2e/tests/journeys.spec.ts
|
||||
git commit -m "fix(compare): render all-secondary comparisons — re-run phase detection after basket hydration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Report-card inspection date through the pipeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `pipeline/plugins/extractors/tap-uk-ofsted/tap_uk_ofsted/tap.py` (COLUMN_PRIORITY, schema, `discover_csv_url`)
|
||||
- Modify: `pipeline/transform/models/staging/stg_ofsted_inspections.sql`
|
||||
- Modify: `pipeline/transform/models/intermediate/int_ofsted_latest.sql`
|
||||
- Modify: `pipeline/transform/models/marts/fact_ofsted_inspection.sql`
|
||||
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (add column doc if other fact_ofsted columns are documented there)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: MI CSV column `Inspection start date` (the latest **full** inspection = the report-card inspection in the renewed framework; NULL when a school's only inspections are legacy OEIF/ungraded — verified for Watford Grammar).
|
||||
- Produces: `marts.fact_ofsted_inspection.rc_inspection_date` (DATE, null unless the row carries report-card grades). Task 3 depends on this exact column name.
|
||||
|
||||
- [ ] **Step 1: Add the tap field**
|
||||
|
||||
In `tap.py` COLUMN_PRIORITY, after the `rc_sixth_form` entry, add:
|
||||
|
||||
```python
|
||||
# Date of the latest FULL inspection — in the renewed framework this is
|
||||
# the report-card inspection's own start date (col "Inspection start
|
||||
# date"), distinct from the legacy OEIF graded/ungraded dates above.
|
||||
"rc_inspection_date": ["Inspection start date"],
|
||||
```
|
||||
|
||||
and in the stream schema, next to the other rc properties:
|
||||
|
||||
```python
|
||||
th.Property("rc_inspection_date", th.StringType),
|
||||
```
|
||||
|
||||
Note: `inspection_date`'s own priority list also contains `"Inspection start date"` as a lower-priority candidate — that stays; in renewed-framework files the higher-priority OEIF column exists so they map to different columns, and in legacy files both map to the same column but rc grades are absent, and staging nulls `rc_inspection_date` in that case (Step 3).
|
||||
|
||||
- [ ] **Step 2: Fix `discover_csv_url` to pick the newest file, not `matches[0]`**
|
||||
|
||||
The GOV.UK page lists 2017 files first; `matches[0]` is a 2017 CSV. Replace the body of `discover_csv_url()` to date-sort the `latest_inspections_as_at` links, mirroring `discover_independent_csv_url`:
|
||||
|
||||
```python
|
||||
def discover_csv_url() -> str | None:
|
||||
"""Scrape GOV.UK page to find the latest MI CSV download link.
|
||||
|
||||
The page lists a decade of monthly files, oldest first — take the
|
||||
newest 'latest inspections as at <date>' link by parsing its date,
|
||||
never matches[0].
|
||||
"""
|
||||
resp = requests.get(GOV_UK_PAGE, timeout=30)
|
||||
resp.raise_for_status()
|
||||
csv_links = re.findall(
|
||||
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
|
||||
resp.text,
|
||||
)
|
||||
|
||||
months = {
|
||||
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
|
||||
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12,
|
||||
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'jun': 6,
|
||||
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
|
||||
}
|
||||
parsed_links = []
|
||||
for link in csv_links:
|
||||
normalized = link.lower().replace('-', '_')
|
||||
if 'latest_inspections_as_at' not in normalized:
|
||||
continue
|
||||
match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized)
|
||||
if match:
|
||||
day, month_str, year = match.groups()
|
||||
month = months.get(month_str)
|
||||
if month:
|
||||
try:
|
||||
parsed_links.append((datetime(int(year), month, int(day)), link))
|
||||
except ValueError:
|
||||
continue
|
||||
parsed_links.sort(reverse=True)
|
||||
if parsed_links:
|
||||
return parsed_links[0][1]
|
||||
if csv_links:
|
||||
return csv_links[-1]
|
||||
matches = re.findall(
|
||||
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
|
||||
resp.text,
|
||||
)
|
||||
return matches[0] if matches else None
|
||||
```
|
||||
|
||||
(`mi_url` config still wins when set — `self.config.get("mi_url") or discover_csv_url()` is unchanged.)
|
||||
|
||||
- [ ] **Step 3: Parse and guard the date in staging**
|
||||
|
||||
In `stg_ofsted_inspections.sql`, inside the `renamed` CTE after the `rc_sixth_form` line, add:
|
||||
|
||||
```sql
|
||||
-- Start date of the latest FULL inspection (the report-card
|
||||
-- inspection in the renewed framework). Guarded below: only kept
|
||||
-- when the row actually carries report-card grades, because in
|
||||
-- legacy-format files this column is the legacy inspection date.
|
||||
to_date(nullif(trim(rc_inspection_date), 'NULL'), 'DD/MM/YYYY') as rc_inspection_date_raw,
|
||||
```
|
||||
|
||||
and replace the final select:
|
||||
|
||||
```sql
|
||||
select
|
||||
*,
|
||||
case
|
||||
when rc_safeguarding_met is not null
|
||||
or rc_inclusion is not null
|
||||
or rc_curriculum_teaching is not null
|
||||
or rc_achievement is not null
|
||||
or rc_attendance_behaviour is not null
|
||||
or rc_personal_development is not null
|
||||
or rc_leadership_governance is not null
|
||||
then rc_inspection_date_raw
|
||||
end as rc_inspection_date
|
||||
from renamed
|
||||
where inspection_date is not null
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Propagate through int + mart**
|
||||
|
||||
Add `rc_inspection_date,` to the explicit column lists of `int_ofsted_latest.sql` and `fact_ofsted_inspection.sql` (after `rc_sixth_form`). Do NOT propagate `rc_inspection_date_raw`.
|
||||
|
||||
- [ ] **Step 5: Parse-check dbt**
|
||||
|
||||
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .`
|
||||
Expected: parse OK, no compilation errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add pipeline/plugins/extractors/tap-uk-ofsted pipeline/transform/models
|
||||
git commit -m "feat(pipeline): carry the report-card inspection's own date; pick newest MI file in discovery"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Report-card date in the API and UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/models.py` (FactOfstedInspection)
|
||||
- Modify: `backend/data_loader.py` (`_ofsted_block`)
|
||||
- Test: `backend/tests/test_supplementary_enrichment.py` (extend the existing `_ofsted_block` tests)
|
||||
- Modify: `nextjs-app/lib/types.ts` (OfstedInspection)
|
||||
- Modify: `nextjs-app/components/compare/CompareOfsted.tsx` ("Inspected" measure)
|
||||
- Test: `nextjs-app/__tests__/components/CompareOfsted.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `marts.fact_ofsted_inspection.rc_inspection_date` (Task 2).
|
||||
- Produces: API `ofsted.rc_inspection_date: string | null` (ISO date). UI rule: report-card displays are dated with `rc_inspection_date` only; when null, show "—" (never the legacy date).
|
||||
|
||||
- [ ] **Step 1: Failing backend test**
|
||||
|
||||
In `backend/tests/test_supplementary_enrichment.py`, alongside the existing `_ofsted_block` tests, add (reuse the file's existing fake-row helper/style):
|
||||
|
||||
```python
|
||||
def test_ofsted_block_carries_rc_inspection_date():
|
||||
o = _fake_ofsted_row( # use this file's existing fake/stub construction
|
||||
overall_effectiveness=None,
|
||||
ungraded_grade=2,
|
||||
rc_achievement=1,
|
||||
rc_inspection_date=date(2026, 2, 3),
|
||||
inspection_date=date(2021, 10, 7),
|
||||
)
|
||||
block = _ofsted_block(o, 138690)
|
||||
assert block["rc_inspection_date"] == "2026-02-03"
|
||||
# The legacy inspection date is still present, unchanged.
|
||||
assert block["inspection_date"] == "2021-10-07"
|
||||
|
||||
|
||||
def test_ofsted_block_rc_inspection_date_none_when_absent():
|
||||
o = _fake_ofsted_row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
|
||||
block = _ofsted_block(o, 136276)
|
||||
assert block["rc_inspection_date"] is None
|
||||
```
|
||||
|
||||
Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_supplementary_enrichment.py -q`
|
||||
Expected: FAIL (KeyError / AttributeError on `rc_inspection_date`).
|
||||
|
||||
- [ ] **Step 2: Backend implementation**
|
||||
|
||||
`backend/models.py`, in `FactOfstedInspection` after `rc_sixth_form`:
|
||||
|
||||
```python
|
||||
# Start date of the report-card inspection itself (renewed framework,
|
||||
# Nov 2025+). Null for rows without report-card grades.
|
||||
rc_inspection_date = Column(Date)
|
||||
```
|
||||
|
||||
`backend/data_loader.py` `_ofsted_block`, after the `"inspection_date"` entry:
|
||||
|
||||
```python
|
||||
"rc_inspection_date": (
|
||||
o.rc_inspection_date.isoformat()
|
||||
if getattr(o, "rc_inspection_date", None)
|
||||
else None
|
||||
),
|
||||
```
|
||||
|
||||
(`getattr` default keeps old test stubs working.) Run the backend suite; expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Failing frontend test**
|
||||
|
||||
`nextjs-app/lib/types.ts`, in `OfstedInspection`, after `inspection_date`:
|
||||
|
||||
```ts
|
||||
/** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
|
||||
rc_inspection_date?: string | null;
|
||||
```
|
||||
|
||||
In `nextjs-app/__tests__/components/CompareOfsted.test.tsx`, add to the existing suite (reusing its fixture style):
|
||||
|
||||
```tsx
|
||||
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
||||
const ofsted = reportCardOfsted({
|
||||
inspection_date: '2021-10-07',
|
||||
rc_inspection_date: '2026-02-03',
|
||||
});
|
||||
render(<CompareOfsted schools={[schoolFixture]} data={{ [String(schoolFixture.urn)]: { ...dataFixture, ofsted } }} />);
|
||||
expect(screen.getByText(/3 Feb 2026/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||
expect(screen.queryByText('4+ years ago')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an em dash when a report card has no rc_inspection_date yet', () => {
|
||||
const ofsted = reportCardOfsted({ inspection_date: '2021-10-07', rc_inspection_date: null });
|
||||
render(<CompareOfsted schools={[schoolFixture]} data={{ [String(schoolFixture.urn)]: { ...dataFixture, ofsted } }} />);
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
(`reportCardOfsted` = the file's existing report-card fixture builder, or build inline matching its other tests.) Run just this file; expected: FAIL.
|
||||
|
||||
- [ ] **Step 4: Frontend implementation**
|
||||
|
||||
In `CompareOfsted.tsx`, replace the body of the "Inspected" measure's map:
|
||||
|
||||
```tsx
|
||||
{schools.map((school, i) => {
|
||||
const ofsted = data[String(school.urn)]?.ofsted;
|
||||
// A report card is dated by its OWN inspection date. The legacy
|
||||
// inspection_date belongs to an older inspection and must never
|
||||
// be shown against a report card (report cards exist only from
|
||||
// Nov 2025).
|
||||
const dateIso =
|
||||
displays[i].kind === 'report_card'
|
||||
? ofsted?.rc_inspection_date ?? null
|
||||
: ofsted?.inspection_date ?? null;
|
||||
const age = yearsSince(dateIso);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{formatInspectionDate(dateIso)}{' '}
|
||||
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run frontend checks**
|
||||
|
||||
Run: `cd nextjs-app && npx tsc --noEmit && npm test`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/models.py backend/data_loader.py backend/tests nextjs-app/lib/types.ts nextjs-app/components/compare/CompareOfsted.tsx nextjs-app/__tests__/components/CompareOfsted.test.tsx
|
||||
git commit -m "fix(compare): date report cards with their own inspection date, never the legacy one"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Census-based context benchmarks; kill the FSM fallback
|
||||
|
||||
**Files:**
|
||||
- Create: `pipeline/transform/models/marts/fact_census_benchmarks.sql`
|
||||
- Modify: `pipeline/transform/models/marts/_marts_schema.yml`
|
||||
- Modify: `backend/models.py` (new `CensusBenchmark` model)
|
||||
- Modify: `backend/data_loader.py` (`compute_benchmarks`)
|
||||
- Modify: `backend/app.py` (compare endpoint call site, only if the signature change requires it)
|
||||
- Test: `backend/tests/test_benchmarks.py`
|
||||
- Modify: `nextjs-app/components/compare/CompareCommunity.tsx:36`
|
||||
- Test: `nextjs-app/__tests__/lib/compareLogic.test.ts` or the community section's existing test home (add a fallback-removal test where the FSM chip logic is tested today)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `marts.fact_pupil_characteristics` (urn, year, phase_type_grouping, total_pupils, fsm_pct, eal_pct).
|
||||
- Produces: `marts.fact_census_benchmarks` — one row per phase (`'primary'`/`'secondary'`), columns `phase, year, fsm_pct, eal_pct, median_pupils`. `fsm_pct`/`eal_pct` are **pupil-weighted means** (so they approximate the national pupil-level rate, answering the expert's objection to school-median anchors). API `benchmarks.{primary,secondary}` keeps its existing keys; `fsm_pct`/`eal_pct`/`median_pupils` now come from this mart; `disadvantaged_pct` becomes primary-only (the KS2-column median was junk for secondary).
|
||||
|
||||
- [ ] **Step 1: dbt mart**
|
||||
|
||||
Create `pipeline/transform/models/marts/fact_census_benchmarks.sql`:
|
||||
|
||||
```sql
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
-- Mart: state-school context benchmarks from the pupil census — one row per
|
||||
-- phase, latest census year. Computed at import time (never per request).
|
||||
-- fsm_pct / eal_pct are pupil-weighted means, i.e. "what % of pupils", not
|
||||
-- "the median school" — this matches how DfE quotes national FSM/EAL rates.
|
||||
-- Consumers must label these "state-school average (computed from our
|
||||
-- dataset)" (spec §8.6), never "England average".
|
||||
|
||||
with latest as (
|
||||
select max(year) as year from {{ ref('fact_pupil_characteristics') }}
|
||||
),
|
||||
|
||||
classified as (
|
||||
select
|
||||
case
|
||||
when p.phase_type_grouping ilike '%primary%' then 'primary'
|
||||
when p.phase_type_grouping ilike '%secondary%' then 'secondary'
|
||||
end as phase,
|
||||
p.total_pupils,
|
||||
p.fsm_pct,
|
||||
p.eal_pct,
|
||||
l.year
|
||||
from {{ ref('fact_pupil_characteristics') }} p
|
||||
join latest l on p.year = l.year
|
||||
where p.total_pupils is not null and p.total_pupils > 0
|
||||
)
|
||||
|
||||
select
|
||||
phase,
|
||||
max(year) as year,
|
||||
round((sum(fsm_pct * total_pupils) filter (where fsm_pct is not null)
|
||||
/ nullif(sum(total_pupils) filter (where fsm_pct is not null), 0))::numeric, 1) as fsm_pct,
|
||||
round((sum(eal_pct * total_pupils) filter (where eal_pct is not null)
|
||||
/ nullif(sum(total_pupils) filter (where eal_pct is not null), 0))::numeric, 1) as eal_pct,
|
||||
round(percentile_cont(0.5) within group (order by total_pupils))::integer as median_pupils
|
||||
from classified
|
||||
where phase is not null
|
||||
group by phase
|
||||
```
|
||||
|
||||
Add a `fact_census_benchmarks` entry to `_marts_schema.yml` in the file's existing style (name + description; column tests only if sibling marts have them).
|
||||
|
||||
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
|
||||
|
||||
- [ ] **Step 2: Failing backend test**
|
||||
|
||||
In `backend/tests/test_benchmarks.py` add:
|
||||
|
||||
```python
|
||||
def test_benchmarks_use_census_mart_for_context(monkeypatch):
|
||||
census = {
|
||||
"primary": {"year": 202425, "fsm_pct": 25.3, "eal_pct": 21.8, "median_pupils": 240},
|
||||
"secondary": {"year": 202425, "fsm_pct": 24.1, "eal_pct": 18.9, "median_pupils": 980},
|
||||
}
|
||||
result = compute_benchmarks(_sample_df(), census_benchmarks=census)
|
||||
assert result["primary"]["fsm_pct"] == 25.3
|
||||
assert result["secondary"]["eal_pct"] == 18.9
|
||||
assert result["secondary"]["median_pupils"] == 980
|
||||
# KS2-only columns must not produce a fake secondary disadvantaged anchor.
|
||||
assert result["secondary"]["disadvantaged_pct"] is None
|
||||
|
||||
|
||||
def test_benchmarks_context_none_when_mart_missing():
|
||||
result = compute_benchmarks(_sample_df(), census_benchmarks=None)
|
||||
assert result["primary"]["fsm_pct"] is None # never silently fall back
|
||||
```
|
||||
|
||||
(`_sample_df()` = this file's existing dataframe fixture.) Run the file; expected: FAIL (unexpected keyword `census_benchmarks`).
|
||||
|
||||
- [ ] **Step 3: Backend implementation**
|
||||
|
||||
`backend/models.py` (next to the national-average models):
|
||||
|
||||
```python
|
||||
class CensusBenchmark(Base):
|
||||
"""State-school context benchmarks from the pupil census — one row per phase."""
|
||||
__tablename__ = "fact_census_benchmarks"
|
||||
__table_args__ = MARTS
|
||||
|
||||
phase = Column(String(20), primary_key=True)
|
||||
year = Column(Integer)
|
||||
fsm_pct = Column(Float) # pupil-weighted mean
|
||||
eal_pct = Column(Float) # pupil-weighted mean
|
||||
median_pupils = Column(Integer)
|
||||
```
|
||||
|
||||
`backend/data_loader.py` — change the signature and `_block`:
|
||||
|
||||
```python
|
||||
def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None) -> dict:
|
||||
```
|
||||
|
||||
Inside, keep `_median` and `_weighted_disadvantaged` as-is, and replace `_block` with:
|
||||
|
||||
```python
|
||||
def _block(sub, phase, with_disadvantaged):
|
||||
census = (census_benchmarks or {}).get(phase) or {}
|
||||
block = {
|
||||
# Context measures come from the census mart (pupil-weighted):
|
||||
# the performance df has no fsm_pct, and its eal/disadvantaged
|
||||
# columns are KS2-only — medianing them for "secondary" produced
|
||||
# junk anchors from the handful of all-through schools.
|
||||
"eal_pct": census.get("eal_pct"),
|
||||
"sen_support_pct": _median(sub, "sen_support_pct"),
|
||||
"disadvantaged_pct": _median(sub, "disadvantaged_pct") if with_disadvantaged else None,
|
||||
"fsm_pct": census.get("fsm_pct"),
|
||||
"median_pupils": census.get("median_pupils"),
|
||||
}
|
||||
if with_disadvantaged:
|
||||
block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
|
||||
return block
|
||||
```
|
||||
|
||||
and the return:
|
||||
|
||||
```python
|
||||
return {
|
||||
"source": "state-school average (computed from our dataset)",
|
||||
"year": int(latest_year),
|
||||
"primary": _block(prim, "primary", with_disadvantaged=True),
|
||||
"secondary": _block(sec, "secondary", with_disadvantaged=False),
|
||||
}
|
||||
```
|
||||
|
||||
In `backend/app.py`'s compare endpoint, load the mart and pass it (same defensive style as the national-averages queries):
|
||||
|
||||
```python
|
||||
census_benchmarks = None
|
||||
try:
|
||||
rows = db.query(CensusBenchmark).all()
|
||||
if rows:
|
||||
census_benchmarks = {
|
||||
r.phase: {
|
||||
"year": r.year,
|
||||
"fsm_pct": r.fsm_pct,
|
||||
"eal_pct": r.eal_pct,
|
||||
"median_pupils": r.median_pupils,
|
||||
}
|
||||
for r in rows
|
||||
}
|
||||
except Exception:
|
||||
db.rollback()
|
||||
...
|
||||
"benchmarks": compute_benchmarks(df, census_benchmarks=census_benchmarks),
|
||||
```
|
||||
|
||||
(Import `CensusBenchmark`; use the endpoint's existing db session pattern.) Run the backend suite; expected: PASS (update any existing benchmark tests that asserted the old median-sourced fsm/eal values).
|
||||
|
||||
- [ ] **Step 4: Frontend — remove the cross-definition fallback**
|
||||
|
||||
`nextjs-app/components/compare/CompareCommunity.tsx:36`:
|
||||
|
||||
```tsx
|
||||
const anchor = bench?.fsm_pct ?? null;
|
||||
```
|
||||
|
||||
If the FSM chip has unit coverage, update/add the case: `anchor` null ⇒ no verdict chip rendered (bare value only). Run `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pipeline/transform/models/marts backend/models.py backend/data_loader.py backend/app.py backend/tests/test_benchmarks.py nextjs-app/components/compare/CompareCommunity.tsx nextjs-app/__tests__
|
||||
git commit -m "fix(compare): census-sourced FSM/EAL benchmarks; never fall back across measure definitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Official KS4 national averages
|
||||
|
||||
**Files:**
|
||||
- Modify: `pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py` (new stream, registered in `discover_streams`)
|
||||
- Create: `pipeline/transform/models/staging/stg_ees_ks4_national.sql`
|
||||
- Modify: `pipeline/transform/models/staging/_stg_sources.yml` (add raw table `ees_ks4_national`)
|
||||
- Modify: `pipeline/transform/models/marts/fact_ks4_national_averages.sql` (rewrite)
|
||||
- Modify: `backend/models.py` (Ks4NationalAverage docstring), `backend/app.py` (`_national_averages_payload` — remove the computed fallback)
|
||||
- Test: `backend/tests/test_national_averages_marts.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: EES data-catalogue CSV `https://explore-education-statistics.service.gov.uk/data-catalogue/data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv` (columns verified: `time_period, geographic_level, establishment_type_group, breakdown_topic, breakdown, attainment8_average, progress8_average, engmath_95_percent, engmath_94_percent, ebacc_entering_percent, ebacc_95_percent, ebacc_94_percent, ebacc_aps_average, …`).
|
||||
- Produces: `marts.fact_ks4_national_averages` with the SAME columns as today (so `Ks4NationalAverage` needs no schema change), now holding official DfE figures; `gcse_grade_91_pct` is NULL (not in the official series — the England anchor for that measure disappears, which is correct: it was noise).
|
||||
|
||||
- [ ] **Step 1: Tap stream**
|
||||
|
||||
In `tap.py`, after the KS2 national stream, add:
|
||||
|
||||
```python
|
||||
# ── KS4 National Headlines (national level only — one row per year) ──────────
|
||||
# Dataset: "National characteristics summary data" (Key stage 4 performance).
|
||||
# Official England state-funded headline measures, 2018/19 → latest.
|
||||
# Suppressed values ('z', 'x') → NULL downstream. Progress 8 is legitimately
|
||||
# absent in years with no KS2 baseline (e.g. 2024/25) — that is DfE policy,
|
||||
# not missing data.
|
||||
|
||||
_KS4_NATIONAL_CSV_URL = (
|
||||
"https://explore-education-statistics.service.gov.uk/data-catalogue/"
|
||||
"data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv"
|
||||
)
|
||||
|
||||
_KS4_NATIONAL_COL_MAP = {
|
||||
"attainment8_average": "attainment_8_score",
|
||||
"progress8_average": "progress_8_score",
|
||||
"engmath_94_percent": "english_maths_standard_pass_pct",
|
||||
"engmath_95_percent": "english_maths_strong_pass_pct",
|
||||
"ebacc_entering_percent": "ebacc_entry_pct",
|
||||
"ebacc_94_percent": "ebacc_standard_pass_pct",
|
||||
"ebacc_95_percent": "ebacc_strong_pass_pct",
|
||||
"ebacc_aps_average": "ebacc_avg_score",
|
||||
}
|
||||
|
||||
|
||||
class EESKs4NationalStream(Stream):
|
||||
"""National KS4 headline averages — one row per academic year.
|
||||
|
||||
Filters to geographic_level == 'National', establishment_type_group ==
|
||||
'All state-funded', breakdown_topic == 'Total', breakdown == 'Total'
|
||||
so only the England-wide all-pupils row per year is emitted.
|
||||
"""
|
||||
|
||||
name = "ees_ks4_national"
|
||||
primary_keys = ["time_period"]
|
||||
replication_key = None
|
||||
|
||||
schema = th.PropertiesList(
|
||||
th.Property("time_period", th.StringType, required=True),
|
||||
*[th.Property(out, th.StringType) for out in _KS4_NATIONAL_COL_MAP.values()],
|
||||
).to_dict()
|
||||
|
||||
def get_records(self, context):
|
||||
import pandas as pd
|
||||
|
||||
self.logger.info("Downloading KS4 national headlines: %s", _KS4_NATIONAL_CSV_URL)
|
||||
resp = requests.get(_KS4_NATIONAL_CSV_URL, timeout=60)
|
||||
resp.raise_for_status()
|
||||
|
||||
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
|
||||
df.columns = [c.strip().lower() for c in df.columns]
|
||||
|
||||
for col, want in [
|
||||
("geographic_level", "national"),
|
||||
("establishment_type_group", "all state-funded"),
|
||||
("breakdown_topic", "total"),
|
||||
("breakdown", "total"),
|
||||
]:
|
||||
if col in df.columns:
|
||||
df = df[df[col].str.strip().str.lower() == want]
|
||||
|
||||
self.logger.info("Emitting %d national KS4 rows", len(df))
|
||||
for _, row in df.iterrows():
|
||||
record = {"time_period": row.get("time_period", "").strip()}
|
||||
for src, out in _KS4_NATIONAL_COL_MAP.items():
|
||||
record[out] = row.get(src, "")
|
||||
yield record
|
||||
```
|
||||
|
||||
Register `EESKs4NationalStream(self)` in `discover_streams` next to the KS2 national stream.
|
||||
|
||||
- [ ] **Step 2: Raw source + staging model**
|
||||
|
||||
Add to `_stg_sources.yml` under the raw source, matching the `ees_ks2_national` entry's style:
|
||||
|
||||
```yaml
|
||||
- name: ees_ks4_national
|
||||
description: Official DfE KS4 national headline averages (EES data catalogue)
|
||||
```
|
||||
|
||||
Create `pipeline/transform/models/staging/stg_ees_ks4_national.sql`:
|
||||
|
||||
```sql
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
-- Staging model: official DfE KS4 national headline averages — one row per
|
||||
-- academic year (England, all state-funded, all pupils). Source: EES data
|
||||
-- catalogue "National characteristics summary data". Suppressed values
|
||||
-- ('z', 'x') are coerced to NULL by safe_numeric — Progress 8 is 'z' in
|
||||
-- years with no KS2 baseline (e.g. 2024/25): legitimately unpublished.
|
||||
|
||||
select
|
||||
cast(trim(time_period) as integer) as year,
|
||||
{{ safe_numeric('attainment_8_score') }} as attainment_8_score,
|
||||
{{ safe_numeric('progress_8_score') }} as progress_8_score,
|
||||
{{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
|
||||
{{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
|
||||
{{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
|
||||
{{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
|
||||
{{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
|
||||
{{ safe_numeric('ebacc_avg_score') }} as ebacc_avg_score
|
||||
from {{ source('raw', 'ees_ks4_national') }}
|
||||
where time_period ~ '^[0-9]+$'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rewrite the mart**
|
||||
|
||||
Replace the entire body of `fact_ks4_national_averages.sql`:
|
||||
|
||||
```sql
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
-- Mart: OFFICIAL DfE KS4 national headline averages — one row per academic
|
||||
-- year (England, state-funded, all pupils), from the EES national dataset.
|
||||
-- Replaces the previous unweighted school-level means, which were 7–15
|
||||
-- points off every headline measure and produced an arithmetically
|
||||
-- impossible national Progress 8. gcse_grade_91_pct has no official
|
||||
-- national series and is NULL (schema kept for the API model).
|
||||
|
||||
select
|
||||
year,
|
||||
attainment_8_score,
|
||||
progress_8_score,
|
||||
english_maths_standard_pass_pct,
|
||||
english_maths_strong_pass_pct,
|
||||
ebacc_entry_pct,
|
||||
ebacc_standard_pass_pct,
|
||||
ebacc_strong_pass_pct,
|
||||
ebacc_avg_score,
|
||||
cast(null as double precision) as gcse_grade_91_pct
|
||||
from {{ ref('stg_ees_ks4_national') }}
|
||||
order by year
|
||||
```
|
||||
|
||||
Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
|
||||
|
||||
- [ ] **Step 4: Backend — official provenance, no computed fallback**
|
||||
|
||||
`backend/models.py`: change the `Ks4NationalAverage` docstring to `"""Official DfE KS4 national headline averages — one row per academic year."""`.
|
||||
|
||||
`backend/app.py` `_national_averages_payload`: delete the entire `if not any(secondary_by_year.values()):` fallback block (it computes dataset means that the UI footnote then labels official). Update the function docstring's KS4 sentence to: `official DfE KS4 figures (fact_ks4_national_averages). If the KS4 mart hasn't been built yet, the secondary series is empty — never a computed stand-in, because the UI labels these figures as official.`
|
||||
|
||||
Update `backend/tests/test_national_averages_marts.py`: the test that exercised the fallback now asserts the opposite —
|
||||
|
||||
```python
|
||||
def test_ks4_secondary_empty_when_mart_missing(...):
|
||||
# No computed stand-in: the UI labels national figures as official DfE
|
||||
# data, so an empty mart must yield an empty secondary series.
|
||||
payload = _national_averages_payload(df)
|
||||
assert all(not e["secondary"] for e in payload["by_year"])
|
||||
```
|
||||
|
||||
(adapt to the file's existing fixtures/monkeypatching). Run the backend suite — expected PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pipeline/plugins/extractors/tap-uk-ees pipeline/transform backend
|
||||
git commit -m "fix(data): official DfE KS4 national headline averages; drop mislabelled computed means"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Honest 2021/22 footnote
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/components/ComparisonChart.tsx:243-247`
|
||||
- Modify: `nextjs-app/lib/compareChartData.ts` (comment lines 7, 53–55 — comments only, no logic)
|
||||
- Modify: `nextjs-app/__tests__/lib/compareChartData.test.ts` (test name/comment wording only)
|
||||
- Modify: `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md` §8.1
|
||||
|
||||
**Interfaces:** none — copy and docs only. This is the one place the plan changes reviewed copy, because the reviewed copy is factually wrong (Global Constraints exception).
|
||||
|
||||
- [ ] **Step 1: Fix the user-facing copy**
|
||||
|
||||
In `ComparisonChart.tsx` replace the note:
|
||||
|
||||
```tsx
|
||||
{built.showUnpublished202122Note && (
|
||||
<p className={styles.chartNote}>
|
||||
No national tests were held in 2019/20 and 2020/21 (COVID), and our dataset doesn't
|
||||
yet include school-level figures for 2021/22 — the England average is shown for that
|
||||
year.
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fix the lying comments**
|
||||
|
||||
In `compareChartData.ts`, update the header comment (line 7) and the `showUnpublished202122Note` doc comment (lines 53–55) to say the 2021/22 school-level figures are *absent from our dataset* (DfE published them in Dec 2022; ingesting them is a backlog pipeline task), not "unpublished". Rename nothing (the flag name stays — pure rename churn). In `compareChartData.test.ts`, adjust the test description/comment wording the same way.
|
||||
|
||||
- [ ] **Step 3: Correct spec §8.1**
|
||||
|
||||
In the spec's §8.1, replace any wording that calls 2021/22 school-level KS2 a "permanent DfE gap" with: DfE published school-level KS2 results for 2021/22 in December 2022 (with comparability caveats); they are not yet ingested — loading them remains an open pipeline task, and the chart footnote says "our dataset doesn't yet include" accordingly.
|
||||
|
||||
- [ ] **Step 4: Verify + commit**
|
||||
|
||||
Run: `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
|
||||
|
||||
```bash
|
||||
git add nextjs-app docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md
|
||||
git commit -m "fix(compare): stop attributing the missing 2021/22 school-level year to DfE"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Full verification, PR, and post-deploy checklist
|
||||
|
||||
**Files:** none new (verification + PR).
|
||||
|
||||
- [ ] **Step 1: Run everything**
|
||||
|
||||
```bash
|
||||
uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q
|
||||
cd nextjs-app && npx tsc --noEmit && npm test && cd ..
|
||||
cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir . && cd ../..
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 2: Open the PR**
|
||||
|
||||
Push `fix/compare-final-review-mustfix`; open a PR via the Gitea API using `git credential fill` basic auth (token-header auth 401s). PR body: summarize the five findings and fixes, link the expert review, end with the standard Claude Code attribution + session URL. Note in the body that findings 2 and 4 also need a **DAG run after the staging deploy** before the UI shows corrected data.
|
||||
|
||||
- [ ] **Step 3: Post-merge staging verification (after the user merges and the daily DAG runs — record results, do not promote)**
|
||||
|
||||
```bash
|
||||
# Report card dated by its own inspection (Barclay): expect 2026-02-03
|
||||
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690" | python3 -c "import json,sys; o=json.load(sys.stdin)['comparison']['138690']['ofsted']; print(o['rc_inspection_date'], o['inspection_date'])"
|
||||
# Stale Watford rc grades cleared by the fresh extract: expect report_card == {}
|
||||
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['comparison']['136276']['ofsted']['report_card'])"
|
||||
# Official KS4 nationals: expect A8 46.0 for 202425, progress_8_score absent
|
||||
curl -sk "https://stx.schoolcompare.co.uk/api/national-averages" | python3 -c "import json,sys; print(json.load(sys.stdin)['secondary'])"
|
||||
# FSM benchmark real (~24-26), secondary disadvantaged_pct gone
|
||||
curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690,136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['benchmarks'])"
|
||||
```
|
||||
|
||||
Then re-screenshot both phase views (desktop + mobile, "More measures" expanded, Watford Grammar in the secondary set) and hand them to the Ofsted expert agent for the sign-off pass it said it expects. Production promotion remains the human's manual call.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (expert should-fix/minor — separate follow-ups)
|
||||
|
||||
- 137086-style interim state (subgrades without an overall from an RI reinspection) rendering treatment (finding 6).
|
||||
- Disadvantaged cohort sizes on the attainment row (finding 7, spec §8.5).
|
||||
- SEN/EAL "typical school" labelling and secondary SEN benchmark (finding 8) — note Task 4 already upgrades EAL to a pupil-weighted census figure.
|
||||
- Selective-school admissions copy variant (finding 9).
|
||||
- Removing/relabelling `gcse_grade_91_pct` as a compare measure (finding 10) — Task 5 already removes its false England anchor.
|
||||
- Palette deviation (11), trends picker label (12), "More measures" expanded-state verification (13).
|
||||
- Actually ingesting the 2021/22 school-level KS2 release (the copy in Task 6 says "doesn't *yet* include").
|
||||
@@ -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,329 @@
|
||||
# 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`; re-verified 2026-07-16
|
||||
after an expert-review challenge — the GOV.UK statistics announcement
|
||||
"Primary school performance tables: 2022" is marked CANCELLED with
|
||||
"will not be published in key stage 2 performance tables in academic
|
||||
year 2021/22", so the footnote's "DfE didn't publish" claim stands
|
||||
and must not be softened to "not in our dataset"). The chart's England-
|
||||
only 2021/22 point with broken school lines is therefore the
|
||||
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>
|
||||
+160
-14
@@ -19,6 +19,40 @@ function schoolLinks(page: Page) {
|
||||
return page.locator('a[href^="/school/"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Two URNs guaranteed to be pure-primary (same phase). The compare page's
|
||||
* phase tabs split all-through schools (which carry KS4 data) onto the
|
||||
* secondary tab, so picking two arbitrary "primary" search hits can land
|
||||
* them on different tabs where only the active one renders. Selecting via
|
||||
* the API by exact phase keeps both on the same tab. Data-invariant: uses
|
||||
* whatever primaries the environment holds.
|
||||
*/
|
||||
async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
|
||||
const res = await page.request.get('/api/schools?search=primary&per_page=50');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
const urns: string[] = (body.schools ?? [])
|
||||
.filter((s: { phase?: string; rwm_expected_pct?: number | null }) =>
|
||||
s.phase === 'Primary' && s.rwm_expected_pct != null,
|
||||
)
|
||||
.map((s: { urn: number }) => String(s.urn));
|
||||
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||
return [urns[0], urns[1]];
|
||||
}
|
||||
|
||||
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
|
||||
const res = await page.request.get('/api/schools?search=school&per_page=100');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
const urns: string[] = (body.schools ?? [])
|
||||
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
|
||||
s.phase === 'Secondary' && s.attainment_8_score != null,
|
||||
)
|
||||
.map((s: { urn: number }) => String(s.urn));
|
||||
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||
return [urns[0], urns[1]];
|
||||
}
|
||||
|
||||
test('home page loads with hero search', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
@@ -138,20 +172,101 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
||||
await expect(openFs).toBeVisible();
|
||||
});
|
||||
|
||||
test('comparing two schools shows both side by side', async ({ page }) => {
|
||||
// Collect two school URNs from search results, then load the share URL
|
||||
await searchByName(page, 'primary');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
const hrefs = await schoolLinks(page).evaluateAll((links) =>
|
||||
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
|
||||
);
|
||||
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
|
||||
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
|
||||
// Two same-phase (pure primary) schools so both stay on one tab.
|
||||
const [urn0, urn1] = await twoPrimaryUrns(page);
|
||||
|
||||
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
|
||||
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||
// Both schools' detail links should render in the comparison view
|
||||
await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible();
|
||||
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(`a[href*="${urn1}"]`).first()).toBeVisible();
|
||||
|
||||
// The parent-first sections render in order (data-invariant: headings only)
|
||||
for (const heading of [
|
||||
'At a glance',
|
||||
'Ofsted inspection',
|
||||
/How (children|students) do academically/,
|
||||
'Who goes there',
|
||||
'Explore trends',
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: heading }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
// Every number gets an anchor: at least one England-average tick or label
|
||||
await expect(page.getByText(/England \d+/).first()).toBeVisible();
|
||||
|
||||
// Desktop: the sticky school bar shares the sections' grid template
|
||||
// (200px label rail + one column per school) so chips align with the
|
||||
// columns they label.
|
||||
const barTemplate = await page
|
||||
.locator('[aria-label="Schools in this comparison"]')
|
||||
.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
|
||||
expect(barTemplate).toMatch(/^200px /);
|
||||
// ...and its label rail carries the comparison caption.
|
||||
await expect(page.getByText(/^\d+ (primary|secondary) schools?$/)).toBeVisible();
|
||||
|
||||
// Ofsted linkout goes to the school's provider page, never a report deep-link
|
||||
const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first();
|
||||
await expect(ofstedLink).toBeVisible();
|
||||
expect(await ofstedLink.getAttribute('href')).toMatch(
|
||||
/reports\.ofsted\.gov\.uk\/provider\/21\/\d+/
|
||||
);
|
||||
|
||||
// A school never shows both an overall-grade badge AND report-card detail:
|
||||
// "Report card" implies "no overall grade is given" copy is present too.
|
||||
const reportCards = await page.getByText('Report card', { exact: true }).count();
|
||||
if (reportCards > 0) {
|
||||
await expect(page.getByText(/no overall grade/i).first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('comparing two secondary schools renders the secondary sections', async ({ page }) => {
|
||||
const [urn0, urn1] = await twoSecondaryUrns(page);
|
||||
|
||||
await page.goto(`/compare?urns=${urn0},${urn1}`);
|
||||
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The parent-first sections must render — this page was completely blank
|
||||
// for all-secondary baskets (expert review must-fix #1).
|
||||
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole('heading', { name: 'Ofsted inspection' }).first()).toBeVisible();
|
||||
// A KS4 measure proves the secondary academics variant rendered.
|
||||
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
|
||||
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
|
||||
|
||||
// The admissions template must be phase-aware: the primaries' distance
|
||||
// copy ("non-faith primaries") must never appear on a secondary comparison
|
||||
// (expert sign-off must-fix M3).
|
||||
await expect(page.getByText(/non-faith primaries/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
|
||||
// Regression: the first visit stores a basket in localStorage; opening a
|
||||
// link for a DIFFERENT school set then raced a stale fetch for the stored
|
||||
// basket against the new SSR data, blanking every section (including the
|
||||
// trends chart) until a hard refresh.
|
||||
const [s0, s1] = await twoSecondaryUrns(page);
|
||||
const [p0, p1] = await twoPrimaryUrns(page);
|
||||
|
||||
await page.goto(`/compare?urns=${s0},${s1}`);
|
||||
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await page.goto(`/compare?urns=${p0},${p1}`);
|
||||
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
// Give any straggling stale response time to land, then confirm the new
|
||||
// comparison is still on screen.
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Explore trends' }).first()).toBeVisible();
|
||||
await expect(page.locator(`a[href*="${p0}"]`).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
|
||||
@@ -170,9 +285,40 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
|
||||
expect(urns.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
|
||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The mobile chart legend renders one chip per school in the active phase.
|
||||
// Mobile is measure-first: the At a glance section stacks all active-phase
|
||||
// schools inside one flow — no horizontal swiping between school columns.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'At a glance' }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
const body = page.locator('body');
|
||||
const bodyOverflowsX = await body.evaluate(
|
||||
(el) => el.scrollWidth > el.clientWidth + 1,
|
||||
);
|
||||
expect(bodyOverflowsX).toBe(false);
|
||||
|
||||
// The sticky school bar must pin *below* the sticky site header, not at
|
||||
// top:0 where the header covers it and the selected schools are hidden.
|
||||
// Assert the sticky offset directly (robust — no scroll timing needed).
|
||||
const barTop = await page
|
||||
.locator('[class*="schoolBar"]')
|
||||
.first()
|
||||
.evaluate((el) => parseFloat(getComputedStyle(el).top));
|
||||
const headerHeight = await page
|
||||
.locator('[class*="header"]')
|
||||
.first()
|
||||
.evaluate((el) => el.getBoundingClientRect().height);
|
||||
expect(barTop).toBeGreaterThanOrEqual(headerHeight - 1);
|
||||
|
||||
// The trends chart still renders (inside the Explore trends section)…
|
||||
const chartCanvas = page.locator('canvas:visible').first();
|
||||
await expect(chartCanvas).toBeVisible({ timeout: 15_000 });
|
||||
// …at a real height, not the squashed ~150px Chart.js fallback that
|
||||
// appears when the container lacks a definite height.
|
||||
const chartBox = await chartCanvas.boundingBox();
|
||||
expect(chartBox && chartBox.height).toBeGreaterThan(220);
|
||||
|
||||
// …with the mobile chart legend chips and tap-to-focus behaviour intact.
|
||||
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
||||
const chips = chipGroup.getByRole('button');
|
||||
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Getting a place — phase and school-type correctness (expert sign-off
|
||||
* must-fixes M1/M3):
|
||||
* - an all-through school's Year 7 round must never render on the primary
|
||||
* tab as if it were Reception odds;
|
||||
* - selective schools get entrance-test framing, and the secondary tab
|
||||
* never shows the primaries' distance template.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { CompareAdmissions } from '@/components/compare/CompareAdmissions';
|
||||
import type { ComparisonData, School, SchoolAdmissions } from '@/lib/types';
|
||||
|
||||
function school(urn: number, name: string, extra: Partial<School> = {}): School {
|
||||
return { urn, school_name: name, ...extra } as School;
|
||||
}
|
||||
|
||||
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
|
||||
return {
|
||||
year: 202627,
|
||||
school_phase: 'Secondary',
|
||||
places_offered: 173,
|
||||
total_applications: 433,
|
||||
first_preference_offer_pct: 83,
|
||||
oversubscribed: true,
|
||||
...partial,
|
||||
} as SchoolAdmissions;
|
||||
}
|
||||
|
||||
function entry(info: School, a: SchoolAdmissions | null): ComparisonData {
|
||||
return {
|
||||
school_info: info,
|
||||
yearly_data: [],
|
||||
ofsted: null,
|
||||
census: null,
|
||||
admissions: a,
|
||||
admissions_history: a ? [a] : [],
|
||||
deprivation: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CompareAdmissions', () => {
|
||||
it("does not show an all-through school's Year 7 round on the primary tab", () => {
|
||||
// The real M1 scenario: an all-through school (Year 7 round only) beside
|
||||
// a primary with a Reception round.
|
||||
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||
const primary = school(138690, 'Barclay Primary School');
|
||||
const data = {
|
||||
'137306': entry(allThrough, admissions({ school_phase: 'Secondary' })),
|
||||
'138690': entry(
|
||||
primary,
|
||||
admissions({
|
||||
school_phase: 'Primary',
|
||||
total_applications: 300,
|
||||
places_offered: 120,
|
||||
first_preference_offer_pct: 96,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
render(<CompareAdmissions schools={[allThrough, primary]} data={data} isSecondary={false} />);
|
||||
|
||||
// Hessle's Year 7 figures must not appear…
|
||||
expect(screen.queryByText('433')).toBeNull();
|
||||
expect(
|
||||
screen.getByText(/We don't hold Reception admissions data for this school/),
|
||||
).toBeInTheDocument();
|
||||
// …while Barclay's Reception round renders normally.
|
||||
expect(screen.getByText('300')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('phase-labels the section empty state when no matching round exists at all', () => {
|
||||
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||
|
||||
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/No Reception admissions data is available for these schools yet/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('433')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the Year 7 round on the secondary tab', () => {
|
||||
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
|
||||
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
|
||||
|
||||
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={true} />);
|
||||
|
||||
expect(screen.getByText('433')).toBeInTheDocument();
|
||||
expect(screen.getByText('173')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives selective schools entrance-test framing, never the distance template', () => {
|
||||
const grammar = school(136276, 'Watford Grammar School for Boys', {
|
||||
admissions_policy: 'Selective',
|
||||
religious_denomination: 'Church of England',
|
||||
});
|
||||
const data = {
|
||||
'136276': entry(grammar, admissions({ first_preference_offer_pct: 43.7 })),
|
||||
};
|
||||
|
||||
render(<CompareAdmissions schools={[grammar]} data={data} isSecondary={true} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Entry is by entrance test — the school is selective/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||
});
|
||||
|
||||
it('secondary faith school gets faith-aware copy, not the primaries template', () => {
|
||||
const faithSchool = school(102052, "Bishop Stopford's School", {
|
||||
admissions_policy: 'Non-selective',
|
||||
religious_denomination: 'Church of England',
|
||||
});
|
||||
const data = {
|
||||
'102052': entry(faithSchool, admissions({ first_preference_offer_pct: 68 })),
|
||||
};
|
||||
|
||||
render(<CompareAdmissions schools={[faithSchool]} data={data} isSecondary={true} />);
|
||||
|
||||
expect(screen.getByText(/faith-based criteria may apply/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the reviewed distance copy for oversubscribed non-faith primaries', () => {
|
||||
const primary = school(100140, 'Plumcroft Primary School');
|
||||
const data = {
|
||||
'100140': entry(
|
||||
primary,
|
||||
admissions({ school_phase: 'Primary', first_preference_offer_pct: 73.4 }),
|
||||
),
|
||||
};
|
||||
|
||||
render(<CompareAdmissions schools={[primary]} data={data} isSecondary={false} />);
|
||||
|
||||
expect(screen.getByText(/for most non-faith primaries, distance decides/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { CompareOfsted } from '@/components/compare/CompareOfsted';
|
||||
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
|
||||
|
||||
function school(urn: number, name: string): School {
|
||||
return { urn, school_name: name } as School;
|
||||
}
|
||||
|
||||
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
|
||||
return {
|
||||
framework: null,
|
||||
inspection_date: '2021-10-07',
|
||||
inspection_type: null,
|
||||
overall_effectiveness: null,
|
||||
quality_of_education: null,
|
||||
behaviour_attitudes: null,
|
||||
personal_development: null,
|
||||
leadership_management: null,
|
||||
early_years_provision: null,
|
||||
previous_overall: null,
|
||||
rc_safeguarding_met: null,
|
||||
rc_inclusion: null,
|
||||
rc_curriculum_teaching: null,
|
||||
rc_achievement: null,
|
||||
rc_attendance_behaviour: null,
|
||||
rc_personal_development: null,
|
||||
rc_leadership_governance: null,
|
||||
rc_early_years: null,
|
||||
rc_sixth_form: null,
|
||||
ofsted_page_url: 'https://reports.ofsted.gov.uk/provider/21/1',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const schools = [school(1, 'Graded School'), school(2, 'Carried School'), school(3, 'Card School')];
|
||||
|
||||
const data: Record<string, ComparisonData> = {
|
||||
'1': {
|
||||
school_info: schools[0],
|
||||
yearly_data: [],
|
||||
ofsted: ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
|
||||
},
|
||||
'2': {
|
||||
school_info: schools[1],
|
||||
yearly_data: [],
|
||||
ofsted: ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
|
||||
},
|
||||
'3': {
|
||||
school_info: schools[2],
|
||||
yearly_data: [],
|
||||
ofsted: ofsted({
|
||||
inspection_date: '2025-11-14',
|
||||
rc_safeguarding_met: true,
|
||||
report_card: {
|
||||
rc_achievement: { code: 2, label: 'Strong standard' },
|
||||
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
describe('CompareOfsted', () => {
|
||||
it('renders the three regimes without inventing an overall grade for report cards', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
|
||||
expect(screen.getByText('Outstanding')).toBeInTheDocument();
|
||||
// Carried-forward grade is shown but marked as such
|
||||
expect(screen.getByText('Good')).toBeInTheDocument();
|
||||
expect(screen.getByText(/carried forward/i)).toBeInTheDocument();
|
||||
// Report card: label present, no overall-grade badge for that school
|
||||
expect(screen.getByText('Report card')).toBeInTheDocument();
|
||||
expect(screen.getByText(/no overall grade/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses one chip-list grammar for both regimes in judgement detail', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
// report-card area chip
|
||||
expect(screen.getByText('Attendance & behaviour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs attention')).toBeInTheDocument();
|
||||
// graded school without published subgrades → honest dataset statement
|
||||
expect(
|
||||
screen.getAllByText(/We don't hold area-by-area detail/i).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('shows the mixed-regime comparability note only when regimes differ', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
expect(screen.getByText(/aren't directly comparable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links every school to its Ofsted page', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
const links = screen.getAllByRole('link', { name: /Ofsted page/i });
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
|
||||
});
|
||||
|
||||
it('never renders Ofsted sentinel codes (9 = not applicable) as judgement chips', () => {
|
||||
const sentinelSchool = school(6, 'Sentinel School');
|
||||
const sentinelData: Record<string, ComparisonData> = {
|
||||
'6': {
|
||||
school_info: sentinelSchool,
|
||||
yearly_data: [],
|
||||
ofsted: ofsted({
|
||||
overall_effectiveness: 2,
|
||||
grade_source: 'graded',
|
||||
quality_of_education: 1,
|
||||
early_years_provision: 9,
|
||||
sixth_form_provision: 2,
|
||||
}),
|
||||
},
|
||||
};
|
||||
render(<CompareOfsted schools={[sentinelSchool]} data={sentinelData} />);
|
||||
// Real grades render…
|
||||
expect(screen.getByText('Quality of education')).toBeInTheDocument();
|
||||
// …the applicable sixth-form judgement renders (was previously dropped)…
|
||||
expect(screen.getByText('Sixth form provision')).toBeInTheDocument();
|
||||
// …and the not-applicable sentinel never appears, neither as area nor code.
|
||||
expect(screen.queryByText('Early years provision')).toBeNull();
|
||||
expect(screen.queryByText('9')).toBeNull();
|
||||
});
|
||||
|
||||
it('dates a report card with the report-card inspection date, never the legacy date', () => {
|
||||
const cardSchool = school(4, 'Dated Card School');
|
||||
const cardData: Record<string, ComparisonData> = {
|
||||
'4': {
|
||||
school_info: cardSchool,
|
||||
yearly_data: [],
|
||||
ofsted: ofsted({
|
||||
inspection_date: '2021-10-07',
|
||||
rc_inspection_date: '2026-02-03',
|
||||
rc_safeguarding_met: true,
|
||||
report_card: { rc_achievement: { code: 1, label: 'Exceptional' } },
|
||||
}),
|
||||
},
|
||||
};
|
||||
render(<CompareOfsted schools={[cardSchool]} data={cardData} />);
|
||||
expect(screen.getByText(/3 Feb 2026/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||
expect(screen.queryByText('4+ years ago')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an em dash when a report card has no rc_inspection_date yet', () => {
|
||||
const cardSchool = school(5, 'Undated Card School');
|
||||
const cardData: Record<string, ComparisonData> = {
|
||||
'5': {
|
||||
school_info: cardSchool,
|
||||
yearly_data: [],
|
||||
ofsted: ofsted({
|
||||
inspection_date: '2021-10-07',
|
||||
rc_inspection_date: null,
|
||||
rc_safeguarding_met: true,
|
||||
report_card: { rc_achievement: { code: 1, label: 'Exceptional' } },
|
||||
}),
|
||||
},
|
||||
};
|
||||
render(<CompareOfsted schools={[cardSchool]} data={cardData} />);
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a per-measure mobile tag with the short school name', () => {
|
||||
render(<CompareOfsted schools={schools} data={data} />);
|
||||
// Each measure repeats the schools, so the short name ("Graded" from
|
||||
// "Graded School") appears once per measure (4) via the cell tag.
|
||||
expect(screen.getAllByText('Graded').length).toBe(4);
|
||||
expect(screen.getAllByText('Card').length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Regression: an all-secondary comparison must render the secondary sections.
|
||||
*
|
||||
* The basket hydrates from the URL a beat after mount, so the auto-phase
|
||||
* effect must re-run once selectedSchools arrives — with deps of only
|
||||
* [comparisonData] it fired once against an empty basket, bailed, and the
|
||||
* page stayed on an empty "primary" tab ("No primary schools in your
|
||||
* comparison") even though all schools were secondary.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { ComparisonView } from '@/components/ComparisonView';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import type { ComparisonData, School } from '@/lib/types';
|
||||
|
||||
const fetchComparison = jest.fn();
|
||||
jest.mock('@/lib/api', () => ({
|
||||
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||
}));
|
||||
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||
|
||||
function secondarySchool(urn: number, name: string): School {
|
||||
return {
|
||||
urn,
|
||||
school_name: name,
|
||||
local_authority: 'Testshire',
|
||||
school_type: 'Academy converter',
|
||||
attainment_8_score: 55,
|
||||
phase: 'Secondary',
|
||||
} as School;
|
||||
}
|
||||
|
||||
function data(urn: number, name: string): ComparisonData {
|
||||
return {
|
||||
school_info: secondarySchool(urn, name),
|
||||
yearly_data: [{ year: 202425, attainment_8_score: 55 }] as ComparisonData['yearly_data'],
|
||||
ofsted: null,
|
||||
census: null,
|
||||
admissions: null,
|
||||
admissions_history: [],
|
||||
deprivation: null,
|
||||
};
|
||||
}
|
||||
|
||||
const INITIAL_DATA = {
|
||||
'300': data(300, 'Gamma High'),
|
||||
'400': data(400, 'Delta Academy'),
|
||||
};
|
||||
|
||||
test('an all-secondary comparison renders the sections, not an empty primary tab', async () => {
|
||||
render(
|
||||
<ComparisonProvider>
|
||||
<ComparisonView
|
||||
initialData={INITIAL_DATA}
|
||||
initialNationalAverages={{
|
||||
year: 202425,
|
||||
primary: {},
|
||||
secondary: { attainment_8_score: 46 },
|
||||
by_year: [],
|
||||
}}
|
||||
initialBenchmarks={undefined}
|
||||
initialUrns={[300, 400]}
|
||||
metrics={[]}
|
||||
selectedMetric="attainment_8_score"
|
||||
/>
|
||||
</ComparisonProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
|
||||
// The sticky bar's rail caption reflects the active phase and count.
|
||||
expect(screen.getByText('2 secondary schools')).toBeInTheDocument();
|
||||
expect(fetchComparison).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Regression: on refresh, the compare page must show the SSR-rendered data.
|
||||
*
|
||||
* The basket hydrates from the URL a beat after mount (selectedSchools is
|
||||
* empty for the first render), so the fetch effect must not blank the
|
||||
* SSR payload during that window — and must not refetch data the server
|
||||
* already provided.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { ComparisonView } from '@/components/ComparisonView';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import type { ComparisonData, School } from '@/lib/types';
|
||||
|
||||
const fetchComparison = jest.fn();
|
||||
jest.mock('@/lib/api', () => ({
|
||||
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||
}));
|
||||
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||
|
||||
function school(urn: number, name: string): School {
|
||||
return {
|
||||
urn,
|
||||
school_name: name,
|
||||
local_authority: 'Testshire',
|
||||
school_type: 'Community school',
|
||||
rwm_expected_pct: 80,
|
||||
phase: 'Primary',
|
||||
} as School;
|
||||
}
|
||||
|
||||
function data(urn: number, name: string): ComparisonData {
|
||||
return {
|
||||
school_info: school(urn, name),
|
||||
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
|
||||
ofsted: null,
|
||||
census: null,
|
||||
admissions: null,
|
||||
admissions_history: [],
|
||||
deprivation: null,
|
||||
};
|
||||
}
|
||||
|
||||
const INITIAL_DATA = {
|
||||
'100': data(100, 'Alpha Primary'),
|
||||
'200': data(200, 'Beta Primary'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchComparison.mockReset();
|
||||
});
|
||||
|
||||
test('renders SSR data on refresh without wiping it or refetching', async () => {
|
||||
render(
|
||||
<ComparisonProvider>
|
||||
<ComparisonView
|
||||
initialData={INITIAL_DATA}
|
||||
initialNationalAverages={{
|
||||
year: 202425,
|
||||
primary: { rwm_expected_pct: 62 },
|
||||
secondary: {},
|
||||
by_year: [],
|
||||
}}
|
||||
initialBenchmarks={undefined}
|
||||
initialUrns={[100, 200]}
|
||||
metrics={[]}
|
||||
selectedMetric="rwm_expected_pct"
|
||||
/>
|
||||
</ComparisonProvider>,
|
||||
);
|
||||
|
||||
// Both SSR-provided schools appear (data was not blanked during hydration)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getAllByText('Beta Primary').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||
|
||||
// …and the client never refetched data the server already rendered.
|
||||
expect(fetchComparison).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Regression: opening a compare link while a DIFFERENT basket is stored must
|
||||
* not blank the page.
|
||||
*
|
||||
* The basket hydrates from localStorage first, which can fire a fetch for the
|
||||
* OLD school set; the URL-seed effect then replaces the basket with the URL's
|
||||
* schools (already covered by SSR data, so no new fetch). When the stale
|
||||
* response for the old set finally lands, it must not clobber the fresh SSR
|
||||
* data — that left every section (including the trends chart) empty until a
|
||||
* hard refresh.
|
||||
*/
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { ComparisonView } from '@/components/ComparisonView';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import type { ComparisonData, School } from '@/lib/types';
|
||||
|
||||
const fetchComparison = jest.fn();
|
||||
jest.mock('@/lib/api', () => ({
|
||||
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
|
||||
}));
|
||||
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
|
||||
|
||||
function school(urn: number, name: string): School {
|
||||
return {
|
||||
urn,
|
||||
school_name: name,
|
||||
local_authority: 'Testshire',
|
||||
school_type: 'Community school',
|
||||
rwm_expected_pct: 80,
|
||||
phase: 'Primary',
|
||||
} as School;
|
||||
}
|
||||
|
||||
function data(urn: number, name: string): ComparisonData {
|
||||
return {
|
||||
school_info: school(urn, name),
|
||||
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
|
||||
ofsted: null,
|
||||
census: null,
|
||||
admissions: null,
|
||||
admissions_history: [],
|
||||
deprivation: null,
|
||||
};
|
||||
}
|
||||
|
||||
// The visitor's previously stored basket (a different school entirely).
|
||||
const STORED_SCHOOL = school(900, 'Old Stored School');
|
||||
|
||||
// The comparison the URL (and SSR) actually asked for.
|
||||
const URL_DATA = {
|
||||
'100': data(100, 'Alpha Primary'),
|
||||
'200': data(200, 'Beta Primary'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchComparison.mockReset();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test('a stale fetch for the previously stored basket does not clobber the URL comparison', async () => {
|
||||
localStorage.setItem('selectedSchools', JSON.stringify([STORED_SCHOOL]));
|
||||
|
||||
const pending: Array<(v: unknown) => void> = [];
|
||||
fetchComparison.mockImplementation(() => new Promise((resolve) => pending.push(resolve)));
|
||||
|
||||
render(
|
||||
<ComparisonProvider>
|
||||
<ComparisonView
|
||||
initialData={URL_DATA}
|
||||
initialNationalAverages={{
|
||||
year: 202425,
|
||||
primary: { rwm_expected_pct: 62 },
|
||||
secondary: {},
|
||||
by_year: [],
|
||||
}}
|
||||
initialBenchmarks={undefined}
|
||||
initialUrns={[100, 200]}
|
||||
metrics={[]}
|
||||
selectedMetric="rwm_expected_pct"
|
||||
/>
|
||||
</ComparisonProvider>,
|
||||
);
|
||||
|
||||
// The URL's schools render from SSR data once the basket is reseeded.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||
|
||||
// The transient stored-basket fetch (for school 900) resolves LATE, after
|
||||
// the basket has moved on to the URL's schools.
|
||||
await act(async () => {
|
||||
for (const resolve of pending) {
|
||||
resolve({
|
||||
comparison: { '900': data(900, 'Old Stored School') },
|
||||
national_averages: { year: 202425, primary: {}, secondary: {}, by_year: [] },
|
||||
benchmarks: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The page must still show the URL comparison — not go blank.
|
||||
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { DotStrip } from '@/components/DotStrip';
|
||||
|
||||
describe('DotStrip', () => {
|
||||
it('enumerates anchor and school values in the aria-label', () => {
|
||||
render(
|
||||
<DotStrip
|
||||
label="Reading"
|
||||
values={[91, 92, 87]}
|
||||
schoolNames={['Barclay', 'Elmhurst', 'Plumcroft']}
|
||||
anchor={{ value: 75, label: 'England 75%' }}
|
||||
/>,
|
||||
);
|
||||
const strip = screen.getByRole('img');
|
||||
expect(strip).toHaveAccessibleName(
|
||||
'Reading: England 75%, Barclay 91%, Elmhurst 92%, Plumcroft 87%',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the anchor tick when provided and not otherwise', () => {
|
||||
const { rerender } = render(
|
||||
<DotStrip
|
||||
label="Reading"
|
||||
values={[91]}
|
||||
schoolNames={['Barclay']}
|
||||
anchor={{ value: 75, label: 'England 75%' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('England 75%')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<DotStrip label="Science" values={[95]} schoolNames={['Barclay']} anchor={null} />,
|
||||
);
|
||||
expect(screen.queryByText(/England/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('skips schools without a value', () => {
|
||||
render(
|
||||
<DotStrip
|
||||
label="Maths"
|
||||
values={[91, null]}
|
||||
schoolNames={['Barclay', 'Elmhurst']}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('img')).toHaveAccessibleName('Maths: Barclay 91%');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* buildCompareChart: every selected school must produce a rendered series
|
||||
* (regression guard for the production bug where a third school's line
|
||||
* vanished), the x-axis must include cancelled/unpublished years as real
|
||||
* gaps (never compressing time), and the England overlay renders dashed
|
||||
* with no gap-bridging.
|
||||
*/
|
||||
|
||||
import { buildCompareChart, fillAcademicYears } from '@/lib/compareChartData';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
|
||||
function school(urn: number, years: Array<[number, number | null]>): ComparisonData {
|
||||
return {
|
||||
school_info: { urn, school_name: `School ${urn}` } as ComparisonData['school_info'],
|
||||
yearly_data: years.map(([year, v]) => ({ year, rwm_expected_pct: v })) as ComparisonData['yearly_data'],
|
||||
};
|
||||
}
|
||||
|
||||
const THREE_SCHOOLS = {
|
||||
'1': school(1, [[201819, 87], [202223, 87], [202425, 87]]),
|
||||
'2': school(2, [[201819, 88], [202223, 88], [202425, 92]]),
|
||||
'3': school(3, [[201819, 69], [202223, 62], [202425, 79]]),
|
||||
};
|
||||
|
||||
const SCHOOL_LIST = [1, 2, 3].map((urn) => ({ urn, school_name: `School ${urn}` }));
|
||||
|
||||
describe('fillAcademicYears', () => {
|
||||
it('fills every academic year between min and max', () => {
|
||||
expect(fillAcademicYears([201819, 202223])).toEqual([
|
||||
201819, 201920, 202021, 202122, 202223,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCompareChart', () => {
|
||||
it('renders one series per selected school — none silently dropped', () => {
|
||||
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||
expect(chart.schoolDatasets).toHaveLength(3);
|
||||
for (const ds of chart.schoolDatasets) {
|
||||
expect(ds.data.some((v) => v != null)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles float years from the API (202425.0 style)', () => {
|
||||
const floaty = {
|
||||
'1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]),
|
||||
};
|
||||
const chart = buildCompareChart(floaty, [SCHOOL_LIST[0]], 'rwm_expected_pct');
|
||||
expect(chart.schoolDatasets[0].data.filter((v) => v != null)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('includes cancelled/unpublished years as null gaps, not compressed time', () => {
|
||||
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||
expect(chart.years).toContain(201920);
|
||||
expect(chart.years).toContain(202122);
|
||||
const idx = chart.years.indexOf(202021);
|
||||
expect(chart.schoolDatasets[0].data[idx]).toBeNull();
|
||||
});
|
||||
|
||||
it('adds a dashed England overlay when national data is supplied', () => {
|
||||
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||
201819: 64.9,
|
||||
202122: 58.7,
|
||||
202223: 59.5,
|
||||
202425: 62.1,
|
||||
});
|
||||
expect(chart.englandDataset).not.toBeNull();
|
||||
const eng = chart.englandDataset!;
|
||||
expect(eng.label).toBe('England average');
|
||||
expect(eng.borderDash).toEqual([5, 4]);
|
||||
expect(eng.spanGaps).toBe(false);
|
||||
// England has a value for 2021/22 even though schools do not
|
||||
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
|
||||
});
|
||||
|
||||
it('lists England-only years so the component can caption dashed-only stretches', () => {
|
||||
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||
202122: 58.7,
|
||||
});
|
||||
expect(chart.englandOnlyYears).toEqual([202122]);
|
||||
const none = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||
expect(none.englandOnlyYears).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
|
||||
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
|
||||
202122: 58.7,
|
||||
});
|
||||
expect(withNational.showUnpublished202122Note).toBe(true);
|
||||
const withoutNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
|
||||
expect(withoutNational.showUnpublished202122Note).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* compareLogic encodes the expert-reviewed comprehension rules for the
|
||||
* compare screen: report-card summarisation (safeguarding never counted),
|
||||
* three-regime Ofsted display, one consistent admissions chip metric,
|
||||
* CI-based progress banding, verdict chips and dot-strip geometry.
|
||||
*/
|
||||
|
||||
import {
|
||||
OFSTED_LEGACY_GRADES,
|
||||
admissionsForPhase,
|
||||
ofstedDisplay,
|
||||
progressBand,
|
||||
rcAreaLabel,
|
||||
stripPositions,
|
||||
summariseAdmissions,
|
||||
summariseReportCard,
|
||||
verdict,
|
||||
} from '@/lib/compareLogic';
|
||||
import type { OfstedInspection, SchoolAdmissions } from '@/lib/types';
|
||||
|
||||
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
|
||||
return {
|
||||
framework: null,
|
||||
inspection_date: null,
|
||||
inspection_type: null,
|
||||
overall_effectiveness: null,
|
||||
quality_of_education: null,
|
||||
behaviour_attitudes: null,
|
||||
personal_development: null,
|
||||
leadership_management: null,
|
||||
early_years_provision: null,
|
||||
previous_overall: null,
|
||||
rc_safeguarding_met: null,
|
||||
rc_inclusion: null,
|
||||
rc_curriculum_teaching: null,
|
||||
rc_achievement: null,
|
||||
rc_attendance_behaviour: null,
|
||||
rc_personal_development: null,
|
||||
rc_leadership_governance: null,
|
||||
rc_early_years: null,
|
||||
rc_sixth_form: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const REPORT_CARD = {
|
||||
rc_achievement: { code: 2, label: 'Strong standard' },
|
||||
rc_curriculum_teaching: { code: 2, label: 'Strong standard' },
|
||||
rc_personal_development: { code: 2, label: 'Strong standard' },
|
||||
rc_leadership_governance: { code: 2, label: 'Strong standard' },
|
||||
rc_inclusion: { code: 3, label: 'Expected standard' },
|
||||
rc_early_years: { code: 3, label: 'Expected standard' },
|
||||
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
|
||||
};
|
||||
|
||||
describe('summariseReportCard', () => {
|
||||
it('counts graded areas best-first and NAMES problem areas', () => {
|
||||
const s = summariseReportCard(
|
||||
ofsted({ report_card: REPORT_CARD, rc_safeguarding_met: true }),
|
||||
);
|
||||
expect(s.counts).toEqual([
|
||||
{ label: 'Strong standard', count: 4 },
|
||||
{ label: 'Expected standard', count: 2 },
|
||||
]);
|
||||
expect(s.problems).toEqual([
|
||||
{ areaLabel: 'Attendance & behaviour', label: 'Needs attention' },
|
||||
]);
|
||||
expect(s.safeguarding).toBe('met');
|
||||
expect(s.allClear).toBe(false);
|
||||
});
|
||||
|
||||
it('never counts safeguarding as a graded area', () => {
|
||||
const s = summariseReportCard(
|
||||
ofsted({
|
||||
report_card: { rc_achievement: { code: 3, label: 'Expected standard' } },
|
||||
rc_safeguarding_met: true,
|
||||
}),
|
||||
);
|
||||
const total = s.counts.reduce((n, c) => n + c.count, 0);
|
||||
expect(total).toBe(1);
|
||||
});
|
||||
|
||||
it('is allClear when everything is Expected standard or better and safeguarding met', () => {
|
||||
const s = summariseReportCard(
|
||||
ofsted({
|
||||
report_card: {
|
||||
rc_achievement: { code: 3, label: 'Expected standard' },
|
||||
rc_inclusion: { code: 1, label: 'Exceptional' },
|
||||
},
|
||||
rc_safeguarding_met: true,
|
||||
}),
|
||||
);
|
||||
expect(s.allClear).toBe(true);
|
||||
expect(s.counts[0]).toEqual({ label: 'Exceptional', count: 1 });
|
||||
});
|
||||
|
||||
it('passes labels through from the API — never invents wording', () => {
|
||||
const s = summariseReportCard(
|
||||
ofsted({ report_card: { rc_inclusion: { code: 4, label: 'Needs attention' } } }),
|
||||
);
|
||||
expect(JSON.stringify(s)).not.toContain('Attention needed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ofstedDisplay', () => {
|
||||
it('prefers the report card over any legacy grade', () => {
|
||||
const d = ofstedDisplay(
|
||||
ofsted({ overall_effectiveness: 2, report_card: REPORT_CARD }),
|
||||
);
|
||||
expect(d.kind).toBe('report_card');
|
||||
});
|
||||
|
||||
it('distinguishes graded from carried-forward grades', () => {
|
||||
const graded = ofstedDisplay(
|
||||
ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
|
||||
);
|
||||
expect(graded).toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', carriedForward: false });
|
||||
|
||||
const carried = ofstedDisplay(
|
||||
ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
|
||||
);
|
||||
expect(carried).toMatchObject({ kind: 'carried_forward', gradeLabel: 'Good', carriedForward: true });
|
||||
});
|
||||
|
||||
it('handles missing data', () => {
|
||||
expect(ofstedDisplay(null).kind).toBe('none');
|
||||
expect(ofstedDisplay(ofsted({})).kind).toBe('none');
|
||||
});
|
||||
|
||||
it('identifies transitional inspections without overall grades', () => {
|
||||
const transitional = ofstedDisplay(
|
||||
ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }),
|
||||
);
|
||||
expect(transitional.kind).toBe('transitional');
|
||||
});
|
||||
|
||||
it('uses the four legacy grade words', () => {
|
||||
expect(OFSTED_LEGACY_GRADES).toEqual({
|
||||
1: 'Outstanding',
|
||||
2: 'Good',
|
||||
3: 'Requires improvement',
|
||||
4: 'Inadequate',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rcAreaLabel', () => {
|
||||
it('maps rc keys to the mockups’ area labels', () => {
|
||||
expect(rcAreaLabel('rc_attendance_behaviour')).toBe('Attendance & behaviour');
|
||||
expect(rcAreaLabel('rc_curriculum_teaching')).toBe('Curriculum & teaching');
|
||||
expect(rcAreaLabel('rc_leadership_governance')).toBe('Leadership & governance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summariseAdmissions', () => {
|
||||
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
|
||||
return {
|
||||
year: 202627,
|
||||
places_offered: null,
|
||||
total_applications: null,
|
||||
first_preference_offer_pct: null,
|
||||
oversubscribed: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
it('97% → good chip with the mockup wording', () => {
|
||||
const s = summariseAdmissions(
|
||||
admissions({ first_preference_offer_pct: 96.98, total_applications: 457, places_offered: 180 }),
|
||||
);
|
||||
expect(s.chip).toEqual({ tone: 'good', text: '97% of first choices offered' });
|
||||
expect(s.interest).toBe('Named on 457 forms · 180 places');
|
||||
});
|
||||
|
||||
it('73% → warn chip "Over 1 in 4 first choices missed out"', () => {
|
||||
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 73.4 }));
|
||||
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
|
||||
});
|
||||
|
||||
it('60% → "About 1 in 3 first choices missed out"', () => {
|
||||
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 60 }));
|
||||
expect(s.chip).toEqual({ tone: 'warn', text: 'About 1 in 3 first choices missed out' });
|
||||
});
|
||||
|
||||
it('44% (selective-scale demand) → "More than half of first choices missed out"', () => {
|
||||
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 43.69 }));
|
||||
expect(s.chip).toEqual({ tone: 'warn', text: 'More than half of first choices missed out' });
|
||||
});
|
||||
|
||||
it('100% → "All first choices offered"', () => {
|
||||
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
|
||||
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
|
||||
});
|
||||
|
||||
it('no data → null chip and interest', () => {
|
||||
const s = summariseAdmissions(null);
|
||||
expect(s.chip).toBeNull();
|
||||
expect(s.interest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admissionsForPhase', () => {
|
||||
const row = (year: number, school_phase: string | null): SchoolAdmissions =>
|
||||
({ year, school_phase, places_offered: 100, total_applications: 200, first_preference_offer_pct: 80 }) as SchoolAdmissions;
|
||||
|
||||
it('returns the latest round matching the active phase', () => {
|
||||
const data = {
|
||||
admissions: row(202627, 'Secondary'),
|
||||
admissions_history: [row(202526, 'Secondary'), row(202526, 'Primary'), row(202425, 'Primary')],
|
||||
};
|
||||
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||
expect(admissionsForPhase(data, false)?.year).toBe(202526);
|
||||
expect(admissionsForPhase(data, false)?.school_phase).toBe('Primary');
|
||||
});
|
||||
|
||||
it("never substitutes the other phase's round (all-through with Year 7 data only)", () => {
|
||||
const data = {
|
||||
admissions: row(202627, 'Secondary'),
|
||||
admissions_history: [row(202526, 'Secondary')],
|
||||
};
|
||||
expect(admissionsForPhase(data, false)).toBeNull();
|
||||
expect(admissionsForPhase(data, true)?.year).toBe(202627);
|
||||
});
|
||||
|
||||
it('uses untagged legacy rows only when no row carries a phase', () => {
|
||||
const untagged = { admissions: row(202627, null), admissions_history: [row(202526, null)] };
|
||||
expect(admissionsForPhase(untagged, false)?.year).toBe(202627);
|
||||
expect(admissionsForPhase(untagged, true)?.year).toBe(202627);
|
||||
|
||||
const mixed = { admissions: row(202627, 'Secondary'), admissions_history: [row(202526, null)] };
|
||||
expect(admissionsForPhase(mixed, false)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles missing data', () => {
|
||||
expect(admissionsForPhase(null, false)).toBeNull();
|
||||
expect(admissionsForPhase({ admissions: null, admissions_history: [] }, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('progressBand', () => {
|
||||
it('CI entirely above zero → above', () => {
|
||||
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
|
||||
});
|
||||
it('CI entirely below zero → below', () => {
|
||||
expect(progressBand(-1.2, -2.0, -0.4)).toBe('below');
|
||||
});
|
||||
it('CI straddling zero → average', () => {
|
||||
expect(progressBand(0.3, -0.5, 1.1)).toBe('average');
|
||||
});
|
||||
it('missing CI → null (no naive thresholding)', () => {
|
||||
expect(progressBand(1.2, null, null)).toBeNull();
|
||||
expect(progressBand(null, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdict', () => {
|
||||
it('above / close / below with a 2pp tolerance', () => {
|
||||
expect(verdict(87, 62)).toBe('above');
|
||||
expect(verdict(61, 62)).toBe('close');
|
||||
expect(verdict(40, 62)).toBe('below');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripPositions', () => {
|
||||
it('maps a custom domain', () => {
|
||||
const pts = stripPositions([106], 100, 120);
|
||||
expect(pts[0].pos).toBe(30);
|
||||
});
|
||||
|
||||
it('flips a colliding label above', () => {
|
||||
const pts = stripPositions([91, 92], 0, 100);
|
||||
const sorted = [...pts].sort((a, b) => a.value - b.value);
|
||||
expect(sorted[0].labelAbove).toBe(false);
|
||||
expect(sorted[1].labelAbove).toBe(true);
|
||||
});
|
||||
|
||||
it('skips nulls and keeps school indices', () => {
|
||||
const pts = stripPositions([50, null, 70], 0, 100);
|
||||
expect(pts).toHaveLength(2);
|
||||
expect(pts.map((p) => p.schoolIndex)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('clamps out-of-domain values', () => {
|
||||
const pts = stripPositions([95], 100, 120);
|
||||
expect(pts[0].pos).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('latestValues', () => {
|
||||
const data = {
|
||||
'1': {
|
||||
yearly_data: [
|
||||
{ year: 202324, rwm_expected_pct: 75 },
|
||||
{ year: 202425, rwm_expected_pct: 87 },
|
||||
],
|
||||
},
|
||||
'2': {
|
||||
yearly_data: [
|
||||
{ year: 202324, rwm_expected_pct: 82 },
|
||||
{ year: 202425, rwm_expected_pct: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('takes the latest non-null value per school in urn order', async () => {
|
||||
const { latestValues } = await import('@/lib/compareLogic');
|
||||
expect(latestValues(data, [1, 2], 'rwm_expected_pct')).toEqual([87, 82]);
|
||||
});
|
||||
|
||||
it('returns null for unknown schools and metrics', async () => {
|
||||
const { latestValues } = await import('@/lib/compareLogic');
|
||||
expect(latestValues(data, [3], 'rwm_expected_pct')).toEqual([null]);
|
||||
expect(latestValues(data, [1], 'nope')).toEqual([null]);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
debounce,
|
||||
buildOfstedListBadge,
|
||||
metricKind,
|
||||
shortName,
|
||||
computeYBounds,
|
||||
} from '@/lib/utils';
|
||||
|
||||
@@ -223,3 +224,17 @@ describe('isProposedToClose', () => {
|
||||
expect(isProposedToClose({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortName', () => {
|
||||
it('drops the trailing establishment-type words', () => {
|
||||
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
||||
expect(shortName('Elmhurst Primary School')).toBe('Elmhurst');
|
||||
expect(shortName("St Mary's Catholic Primary School")).toBe("St Mary's");
|
||||
expect(shortName('Riverside Community Junior School')).toBe('Riverside');
|
||||
});
|
||||
|
||||
it('keeps a name that carries no type suffix, capping very long ones', () => {
|
||||
expect(shortName('Beaver Road')).toBe('Beaver Road');
|
||||
expect(shortName('A'.repeat(30), 10)).toBe('AAAAAAAAA…');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,10 @@ interface ComparePageProps {
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Compare Schools',
|
||||
description: 'Compare KS2 performance across multiple primary schools in England',
|
||||
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
|
||||
description:
|
||||
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
||||
keywords:
|
||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||
};
|
||||
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
@@ -30,26 +32,24 @@ export default async function ComparePage({ searchParams }: ComparePageProps) {
|
||||
const selectedMetric = metricParam || 'rwm_expected_pct';
|
||||
|
||||
try {
|
||||
// Fetch comparison data if URNs provided
|
||||
let comparisonData = null;
|
||||
if (urns.length > 0) {
|
||||
try {
|
||||
const response = await fetchComparison(urnsParam!);
|
||||
comparisonData = response.comparison;
|
||||
} catch (error) {
|
||||
// Fetch comparison + metrics in parallel — they are independent.
|
||||
const [comparisonResponse, metricsResponse] = await Promise.all([
|
||||
urns.length > 0
|
||||
? fetchComparison(urnsParam!).catch((error) => {
|
||||
console.error('Failed to fetch comparison:', error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
fetchMetrics(),
|
||||
]);
|
||||
|
||||
// Fetch available metrics
|
||||
const metricsResponse = await fetchMetrics();
|
||||
|
||||
// Metrics is already an array
|
||||
const metricsArray = metricsResponse?.metrics || [];
|
||||
|
||||
return (
|
||||
<ComparisonView
|
||||
initialData={comparisonData}
|
||||
initialData={comparisonResponse?.comparison ?? null}
|
||||
initialNationalAverages={comparisonResponse?.national_averages}
|
||||
initialBenchmarks={comparisonResponse?.benchmarks}
|
||||
initialUrns={urns}
|
||||
metrics={metricsArray}
|
||||
selectedMetric={selectedMetric}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
|
||||
parent .chartContainer, whose fixed height drives Chart.js sizing via
|
||||
maintainAspectRatio: false. */
|
||||
/* Chart wrapper: chips (mobile) above, then the canvas, then the gap note.
|
||||
The canvas has its OWN definite height (Chart.js needs one for
|
||||
maintainAspectRatio: false); the chips and the note flow at their natural
|
||||
size around it rather than competing with it for a fixed outer height —
|
||||
so a longer note (e.g. the KS4 gap caption) or a two-row chip legend can
|
||||
never squash the chart. */
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.canvasBox {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 380px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.canvasBox {
|
||||
height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
|
||||
@@ -65,3 +72,9 @@
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chartNote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { buildCompareChart } from '@/lib/compareChartData';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
import {
|
||||
CHART_COLORS,
|
||||
@@ -34,13 +35,18 @@ interface ComparisonChartProps {
|
||||
schools: Array<{ urn: number; school_name: string }>;
|
||||
metric: string;
|
||||
metricLabel: string;
|
||||
/** Official England figure per academic year for this metric — renders a
|
||||
* dashed grey reference line when provided. */
|
||||
nationalByYear?: Record<number, number | null | undefined>;
|
||||
/** KS4 metrics get a different (honest) gap caption than KS2. */
|
||||
isSecondary?: boolean;
|
||||
}
|
||||
|
||||
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
||||
// converging lines stay tellable apart without relying on hue alone.
|
||||
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
||||
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) {
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||
|
||||
@@ -54,34 +60,48 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
|
||||
return <div>No data available</div>;
|
||||
}
|
||||
|
||||
// Union of years across all schools — coverage differs between them.
|
||||
const years = [
|
||||
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
|
||||
].sort((a, b) => a - b);
|
||||
// Pure, tested series construction: union of years with cancelled /
|
||||
// unpublished years kept as real gaps, plus the England overlay.
|
||||
const built = buildCompareChart(comparisonData, schools, metric, nationalByYear);
|
||||
const { years } = built;
|
||||
|
||||
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
|
||||
const data = comparisonData[String(school.urn)];
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||||
const school = schools[series.schoolIndex];
|
||||
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
|
||||
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||
|
||||
return {
|
||||
label: school.school_name,
|
||||
data: years.map((year) => {
|
||||
const yearData = data?.yearly_data.find((d) => d.year === year);
|
||||
if (!yearData) return null;
|
||||
return yearData[metric as keyof typeof yearData] as number | null;
|
||||
}),
|
||||
label: series.label,
|
||||
data: series.data,
|
||||
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
|
||||
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
|
||||
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
|
||||
pointStyle: POINT_STYLES[index % POINT_STYLES.length],
|
||||
pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length],
|
||||
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
|
||||
pointHoverRadius: isMobile ? 5 : 6,
|
||||
tension: 0.3,
|
||||
spanGaps: true,
|
||||
// Never bridge missing years — gaps are information (COVID
|
||||
// cancellations, unpublished 2021/22, schools that opened later).
|
||||
spanGaps: false,
|
||||
};
|
||||
});
|
||||
|
||||
if (built.englandDataset) {
|
||||
datasets.push({
|
||||
label: built.englandDataset.label,
|
||||
data: built.englandDataset.data,
|
||||
borderColor: 'rgba(109, 104, 95, 0.9)',
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: built.englandDataset.borderDash,
|
||||
pointStyle: 'line',
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
tension: 0,
|
||||
spanGaps: false,
|
||||
});
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels: years.map(formatAcademicYear),
|
||||
datasets,
|
||||
@@ -150,7 +170,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
|
||||
display: true,
|
||||
title: {
|
||||
display: !isMobile,
|
||||
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
|
||||
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'bold',
|
||||
@@ -222,6 +242,24 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
|
||||
<div className={styles.canvasBox}>
|
||||
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
||||
</div>
|
||||
{isSecondary && built.englandOnlyYears.length > 0 ? (
|
||||
// KS4's honest story differs from KS2's: 2019/20–2020/21 school-level
|
||||
// GCSE results weren't published (COVID grading); later years WERE
|
||||
// published by DfE but aren't in our dataset yet.
|
||||
<p className={styles.chartNote}>
|
||||
School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID
|
||||
grading), and more recent years aren't in our dataset yet where lines break — the
|
||||
England average is shown where available.
|
||||
</p>
|
||||
) : (
|
||||
!isSecondary &&
|
||||
built.showUnpublished202122Note && (
|
||||
<p className={styles.chartNote}>
|
||||
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish
|
||||
school-level figures for 2021/22 — the England average is shown for that year.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,15 @@
|
||||
color: var(--text-secondary, #5c564d);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Phase Tabs */
|
||||
.phaseTabs {
|
||||
@@ -72,408 +79,184 @@
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
}
|
||||
|
||||
/* Metric Selector */
|
||||
.metricSelector {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||
narrow screens. Offset by the sticky site header's height (Navigation is
|
||||
position: sticky, top: 0) so this bar pins just below it instead of
|
||||
sliding underneath and being hidden. Header ≈ 65px desktop / 57px mobile. */
|
||||
.schoolBar {
|
||||
position: sticky;
|
||||
top: 65px;
|
||||
z-index: 10;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metricSelect {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-primary, #1a1612);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.metricSelect:hover {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.metricSelect:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
}
|
||||
|
||||
.metricSelect optgroup {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.metricSelect option {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
|
||||
/* Schools Section */
|
||||
.schoolsSection {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.schoolsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.schoolCard {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schoolCard:hover {
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.removeButton:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-right: 2rem;
|
||||
line-height: 1.3;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
.schoolName a {
|
||||
color: var(--text-primary, #1a1612);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.schoolName a:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
.schoolMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.latestValue {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
text-align: center;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
margin-bottom: -1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 0 0 12px 9px;
|
||||
}
|
||||
|
||||
.latestLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.latestNumber {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
/* Chart Section */
|
||||
.chartSection {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 1em;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loadingMessage {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Table Section */
|
||||
.tableSection {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Right-edge fade so phone users see the comparison table scrolls.
|
||||
Otherwise the wider-than-viewport table silently clips. */
|
||||
@media (max-width: 640px) {
|
||||
.tableWrapper {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
}
|
||||
.schoolChip {
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 3px solid var(--accent-coral, #e07256);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.comparisonTable {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.9375rem;
|
||||
.chipDot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.comparisonTable thead {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
.chipText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comparisonTable th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
.chipName {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chipName:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
/* Full name on desktop, short name on the compact mobile pills. */
|
||||
.chipNameShort {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chipMeta {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.comparisonTable td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
text-align: left;
|
||||
background: var(--bg-card, white);
|
||||
/* Caption filling the label rail on desktop ("Comparing / 3 primary
|
||||
schools"). Hidden on mobile, where the bar is a row of compact pills. */
|
||||
.barCaption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Sticky first column (Year) so labels remain visible while scrolling */
|
||||
.comparisonTable th:first-child,
|
||||
.comparisonTable td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
box-shadow: 2px 0 4px -2px rgba(26, 22, 18, 0.08);
|
||||
/* Desktop (matches the sections' 761px breakpoint): the bar adopts the same
|
||||
grid template as compareSections' .grid — a 200px row-label rail plus one
|
||||
column per school — so each chip sits exactly over the column it labels.
|
||||
The caption occupies the rail; chips flow into the school columns. */
|
||||
@media (min-width: 761px) {
|
||||
.schoolBar {
|
||||
display: grid;
|
||||
grid-template-columns: 200px repeat(var(--school-count, 3), 1fr);
|
||||
gap: 0 0.75rem;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.comparisonTable thead th:first-child {
|
||||
z-index: 2;
|
||||
.schoolChip {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comparisonTable tbody tr:hover td:first-child {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
.barCaption {
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
padding-right: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comparisonTable tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comparisonTable tbody tr:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.yearCell {
|
||||
font-weight: 700;
|
||||
color: var(--accent-gold, #c9a227);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: 1.5rem;
|
||||
.barCaptionEyebrow {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.barCaptionCount {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyStateDescription {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
max-width: 400px;
|
||||
margin: 0 auto 1.5rem;
|
||||
.chipRemove {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #6d685f);
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metricDescription {
|
||||
margin-top: 0.5rem;
|
||||
.footnote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--border-light, #e5dfd5);
|
||||
padding-top: 1rem;
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
/* Mobile: the sticky school bar becomes compact, horizontally-scrollable
|
||||
pills with short names (matching the mobile mockup) instead of full-width
|
||||
cards whose names wrap to several lines. */
|
||||
@media (max-width: 640px) {
|
||||
/* The mobile Navigation header is shorter (≈57px). */
|
||||
.schoolBar {
|
||||
top: 57px;
|
||||
}
|
||||
|
||||
.schoolChip {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
border-top-width: 2px;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.chipName {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
flex-basis: 100%;
|
||||
margin-top: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progressNote {
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--accent-teal);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
.chipNameFull {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.headerContent {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
.chipNameShort {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.75rem;
|
||||
.chipMeta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.metricSelector {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metricSelect {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.schoolsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chartSection,
|
||||
.tableSection {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
/* Taller than desktop's proportion would suggest: the chip legend row
|
||||
sits inside, and the in-chart title/legend/axis titles are gone, so
|
||||
nearly all of this is plot area. */
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.comparisonTable {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.comparisonTable th,
|
||||
.comparisonTable td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.latestValue {
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
margin-bottom: -1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0 0 8px 5px;
|
||||
.chipRemove {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,42 @@
|
||||
/**
|
||||
* ComparisonView Component
|
||||
* Client-side comparison interface with phase tabs, charts, and tables
|
||||
* ComparisonView — the parent-first compare screen: a sticky school bar and
|
||||
* six sections (At a glance / Ofsted / Academics / Getting a place / Who
|
||||
* goes there / Explore trends), every number anchored against the England
|
||||
* average or the computed state-school benchmark with provenance-correct
|
||||
* labels. Layout and copy follow the reviewed mockups
|
||||
* (docs/superpowers/specs/mockups/).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
|
||||
const ComparisonChart = dynamic(
|
||||
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils';
|
||||
import { CompareAtAGlance } from './compare/CompareAtAGlance';
|
||||
import { CompareOfsted } from './compare/CompareOfsted';
|
||||
import { CompareAcademics } from './compare/CompareAcademics';
|
||||
import { CompareAdmissions } from './compare/CompareAdmissions';
|
||||
import { CompareCommunity } from './compare/CompareCommunity';
|
||||
import { TrendsExplorer, PRIMARY_CATEGORIES, SECONDARY_CATEGORIES } from './compare/TrendsExplorer';
|
||||
import type {
|
||||
Benchmarks,
|
||||
ComparisonData,
|
||||
MetricDefinition,
|
||||
NationalAverages,
|
||||
School,
|
||||
} from '@/lib/types';
|
||||
import { CHART_COLORS, schoolUrl, shortName } from '@/lib/utils';
|
||||
import { fetchComparison } from '@/lib/api';
|
||||
import { track } from '@/lib/analytics';
|
||||
import styles from './ComparisonView.module.css';
|
||||
|
||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||
const SECONDARY_CATEGORIES = ['gcse'];
|
||||
|
||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'Expected Standard', category: 'expected' },
|
||||
{ label: 'Higher Standard', category: 'higher' },
|
||||
{ label: 'Progress Scores', category: 'progress' },
|
||||
{ label: 'Average Scores', category: 'average' },
|
||||
{ label: 'Gender Performance', category: 'gender' },
|
||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||
{ label: 'School Context', category: 'context' },
|
||||
{ label: 'Absence', category: 'absence' },
|
||||
{ label: '3-Year Trends', category: 'trends' },
|
||||
];
|
||||
|
||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'GCSE Performance', category: 'gcse' },
|
||||
];
|
||||
|
||||
interface ComparisonViewProps {
|
||||
initialData: Record<string, ComparisonData> | null;
|
||||
initialNationalAverages?: NationalAverages;
|
||||
initialBenchmarks?: Benchmarks;
|
||||
initialUrns: number[];
|
||||
metrics: MetricDefinition[];
|
||||
selectedMetric: string;
|
||||
@@ -51,6 +44,8 @@ interface ComparisonViewProps {
|
||||
|
||||
export function ComparisonView({
|
||||
initialData,
|
||||
initialNationalAverages,
|
||||
initialBenchmarks,
|
||||
initialUrns,
|
||||
metrics,
|
||||
selectedMetric: initialMetric,
|
||||
@@ -58,11 +53,15 @@ export function ComparisonView({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison();
|
||||
const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
|
||||
|
||||
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [comparisonData, setComparisonData] = useState(initialData);
|
||||
const [nationalAverages, setNationalAverages] = useState<NationalAverages | undefined>(
|
||||
initialNationalAverages,
|
||||
);
|
||||
const [benchmarks, setBenchmarks] = useState<Benchmarks | undefined>(initialBenchmarks);
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||
// Tracks whether the user has explicitly clicked a phase tab.
|
||||
@@ -77,24 +76,29 @@ export function ComparisonView({
|
||||
if (!isInitialized) return;
|
||||
if (initialUrns.length > 0 && initialData) {
|
||||
const urlSchools = initialUrns
|
||||
.map(urn => initialData[String(urn)]?.school_info)
|
||||
.map((urn) => initialData[String(urn)]?.school_info)
|
||||
.filter((info): info is NonNullable<typeof info> => Boolean(info));
|
||||
const sameSet =
|
||||
urlSchools.length === selectedSchools.length &&
|
||||
urlSchools.every(s => selectedSchools.some(sel => sel.urn === s.urn));
|
||||
urlSchools.every((s) => selectedSchools.some((sel) => sel.urn === s.urn));
|
||||
if (urlSchools.length > 0 && !sameSet) {
|
||||
replaceSchools(urlSchools);
|
||||
}
|
||||
}
|
||||
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Re-seed when a client-side navigation lands on a different ?urns= set
|
||||
// (initialUrns/initialData are new props on the same component instance).
|
||||
}, [isInitialized, initialUrns.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Sync URL with selected schools
|
||||
const urnKey = selectedSchools.map((s) => s.urn).join(',');
|
||||
|
||||
// Sync the URL with the selection + metric. Pure navigation state — no
|
||||
// fetching here: metric changes are presentational (the data is already
|
||||
// client-side) and must not refire the comparison request.
|
||||
useEffect(() => {
|
||||
const urns = selectedSchools.map((s) => s.urn).join(',');
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (urns) {
|
||||
params.set('urns', urns);
|
||||
if (urnKey) {
|
||||
params.set('urns', urnKey);
|
||||
} else {
|
||||
params.delete('urns');
|
||||
}
|
||||
@@ -103,84 +107,114 @@ export function ComparisonView({
|
||||
|
||||
const newUrl = `${pathname}?${params.toString()}`;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
}, [urnKey, selectedMetric, pathname, searchParams, router]);
|
||||
|
||||
// Fetch comparison data
|
||||
if (selectedSchools.length > 0) {
|
||||
fetchComparison(urns, { cache: 'no-store' })
|
||||
// Fetch when the school set changes, but only for schools we don't already
|
||||
// have data for. This skips the refetch of SSR-rendered data on load AND
|
||||
// avoids a network call when a school is merely removed. A ref holds the
|
||||
// latest data so the effect can read it without re-running on every fetch.
|
||||
//
|
||||
// Correctness note: we must NOT null the data on a transient empty urnKey.
|
||||
// On mount the basket is empty for a beat before it hydrates from the URL,
|
||||
// and blanking here (then skipping the refetch because SSR "covers" the set)
|
||||
// was leaving the page empty on refresh. The render already shows the empty
|
||||
// state whenever `selectedSchools` is empty, so stale data for deselected
|
||||
// schools is harmless — it's simply unused.
|
||||
const comparisonDataRef = useRef(comparisonData);
|
||||
comparisonDataRef.current = comparisonData;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized || !urnKey) return;
|
||||
|
||||
const have = comparisonDataRef.current ?? {};
|
||||
const covered = urnKey.split(',').every((urn) => have[urn] != null);
|
||||
if (covered) return;
|
||||
|
||||
// Guard against out-of-order responses: while the basket hydrates from
|
||||
// localStorage it can transiently hold a DIFFERENT school set than the
|
||||
// URL, firing a fetch for schools the user is no longer comparing. That
|
||||
// stale response must not replace data for the current set — it blanked
|
||||
// every section until a hard refresh. Cleanup marks the run cancelled
|
||||
// when urnKey moves on, so only the current selection's response is
|
||||
// applied (replacing the map keeps it bounded and guarantees a re-added
|
||||
// school is refetched fresh rather than served a lingering old entry).
|
||||
let cancelled = false;
|
||||
fetchComparison(urnKey, { cache: 'no-store' })
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setComparisonData(data.comparison);
|
||||
setNationalAverages(data.national_averages);
|
||||
setBenchmarks(data.benchmarks);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Keep whatever we already have (SSR data or a previous fetch) rather
|
||||
// than blanking the chart — a transient refetch failure shouldn't
|
||||
// than blanking the page — a transient refetch failure shouldn't
|
||||
// destroy a working comparison the user is looking at.
|
||||
console.error('Failed to fetch comparison:', err);
|
||||
});
|
||||
} else {
|
||||
setComparisonData(null);
|
||||
}
|
||||
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
||||
|
||||
// Classify schools by phase using comparison data
|
||||
const classifySchool = (school: School): 'primary' | 'secondary' => {
|
||||
const info = comparisonData?.[school.urn]?.school_info;
|
||||
if (info?.attainment_8_score != null) return 'secondary';
|
||||
if (info?.rwm_expected_pct != null) return 'primary';
|
||||
// Fallback: check yearly data
|
||||
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
|
||||
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
|
||||
return 'primary';
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [urnKey, isInitialized]);
|
||||
|
||||
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
||||
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
||||
const primarySchools = selectedSchools.filter((school) => {
|
||||
const info = comparisonData?.[school.urn]?.school_info;
|
||||
const hasPrimaryData =
|
||||
info?.rwm_expected_pct != null ||
|
||||
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.rwm_expected_pct != null);
|
||||
if (hasPrimaryData) return true;
|
||||
return school.phase?.toLowerCase().includes('primary') || false;
|
||||
});
|
||||
|
||||
// Auto-select tab with more schools and sync the metric to match the detected phase.
|
||||
// This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct)
|
||||
// but the shortlisted schools are secondary — the phase tab switches but the metric
|
||||
// needs to follow, otherwise all secondary cards show "–" for a primary-only field.
|
||||
const secondarySchools = selectedSchools.filter((school) => {
|
||||
const info = comparisonData?.[school.urn]?.school_info;
|
||||
const hasSecondaryData =
|
||||
info?.attainment_8_score != null ||
|
||||
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.attainment_8_score != null);
|
||||
if (hasSecondaryData) return true;
|
||||
return school.phase?.toLowerCase().includes('secondary') || false;
|
||||
});
|
||||
|
||||
// Auto-select tab with more schools and sync the metric to match the phase.
|
||||
useEffect(() => {
|
||||
if (!comparisonData || selectedSchools.length === 0) return;
|
||||
if (phaseLockedByUser.current) return;
|
||||
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
||||
setComparePhase(newPhase);
|
||||
// Only reset the metric when it doesn't belong to the newly detected phase.
|
||||
// This preserves a correct metric that came from the URL (e.g. metric=attainment_8_score).
|
||||
const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
|
||||
const metricFitsPhase = metrics.some(
|
||||
(m) => m.key === selectedMetric && phaseCategories.includes(m.category)
|
||||
(m) => m.key === selectedMetric && phaseCategories.includes(m.category),
|
||||
);
|
||||
if (!metricFitsPhase) {
|
||||
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||
}
|
||||
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// selectedSchools is a dep because the basket hydrates after mount: the
|
||||
// first run sees an empty basket and bails, so it must re-fire when the
|
||||
// schools arrive. primarySchools/secondarySchools/metrics/selectedMetric
|
||||
// are intentionally omitted (derived or would cause loops).
|
||||
}, [comparisonData, selectedSchools]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||
phaseLockedByUser.current = true;
|
||||
setComparePhase(phase);
|
||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
setSelectedMetric(defaultMetric);
|
||||
setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||
};
|
||||
|
||||
// compare_viewed: fire once after the page has its first selection.
|
||||
// We watch `selectedSchools.length` going from 0 → ≥1 so the event is
|
||||
// sent only when there's actual content to view, not for empty arrivals.
|
||||
const compareViewedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (compareViewedRef.current) return;
|
||||
if (selectedSchools.length === 0) return;
|
||||
compareViewedRef.current = true;
|
||||
const primaryCount = selectedSchools.filter(s => s.phase?.toLowerCase().includes('primary')).length;
|
||||
const primaryCount = selectedSchools.filter((s) =>
|
||||
s.phase?.toLowerCase().includes('primary'),
|
||||
).length;
|
||||
const secondaryCount = selectedSchools.length - primaryCount;
|
||||
const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
|
||||
const phaseMix =
|
||||
primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
|
||||
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
|
||||
}, [selectedSchools]);
|
||||
|
||||
const handleMetricChange = (metric: string) => {
|
||||
track('compare_metric_changed', { metric, phase: comparePhase });
|
||||
setSelectedMetric(metric);
|
||||
};
|
||||
|
||||
const handleRemoveSchool = (urn: number) => {
|
||||
removeSchool(urn);
|
||||
track('compare_school_removed', { urn, from: 'compare' });
|
||||
@@ -191,21 +225,22 @@ export function ComparisonView({
|
||||
const count = selectedSchools.length;
|
||||
const shareData = {
|
||||
title: 'School comparison · SchoolCompare',
|
||||
text: count > 0
|
||||
text:
|
||||
count > 0
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||
: 'SchoolCompare',
|
||||
url,
|
||||
};
|
||||
// Prefer the native share sheet on platforms that support it (iOS / Android).
|
||||
// canShare is feature-detected because Safari iOS exposes share() but
|
||||
// some configurations refuse the payload.
|
||||
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.share &&
|
||||
(!navigator.canShare || navigator.canShare(shareData))
|
||||
) {
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
track('compare_shared', { method: 'native', school_count: count });
|
||||
return;
|
||||
} catch (err) {
|
||||
// User cancelled — bail silently. Any other error falls through to clipboard.
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
@@ -214,27 +249,22 @@ export function ComparisonView({
|
||||
track('compare_shared', { method: 'clipboard', school_count: count });
|
||||
setShareConfirm(true);
|
||||
setTimeout(() => setShareConfirm(false), 2000);
|
||||
} catch { /* fallback: do nothing */ }
|
||||
} catch {
|
||||
/* fallback: do nothing */
|
||||
}
|
||||
};
|
||||
|
||||
const isPrimary = comparePhase === 'primary';
|
||||
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
||||
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
||||
|
||||
// Get metric definition
|
||||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
||||
const metricLabel = currentMetricDef?.label || selectedMetric;
|
||||
|
||||
// No schools selected
|
||||
if (selectedSchools.length === 0) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<h1>Compare Schools</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Add schools to your comparison basket to see side-by-side performance data
|
||||
Add schools to your comparison basket to see them side by side — inspection results,
|
||||
academics, admissions and community.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -252,39 +282,46 @@ export function ComparisonView({
|
||||
);
|
||||
}
|
||||
|
||||
// Build filtered comparison data for active phase
|
||||
// Build filtered comparison data for the active phase
|
||||
const activeComparisonData: Record<string, ComparisonData> = {};
|
||||
if (comparisonData) {
|
||||
activeSchools.forEach(s => {
|
||||
activeSchools.forEach((s) => {
|
||||
if (comparisonData[s.urn]) {
|
||||
activeComparisonData[s.urn] = comparisonData[s.urn];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get years for table
|
||||
const years =
|
||||
Object.keys(activeComparisonData).length > 0
|
||||
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
||||
: [];
|
||||
const hasData = Object.keys(activeComparisonData).length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Header */}
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<div>
|
||||
<h1>Compare Schools</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''}
|
||||
{selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''} side by side
|
||||
— each number anchored against the England average so you can tell at a glance
|
||||
what's typical and what stands out.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div className={styles.headerActions}>
|
||||
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
|
||||
+ Add School
|
||||
</button>
|
||||
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
{shareConfirm ? 'Copied!' : 'Share'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -292,6 +329,7 @@ export function ComparisonView({
|
||||
</header>
|
||||
|
||||
{/* Phase Tabs */}
|
||||
{secondarySchools.length > 0 && primarySchools.length > 0 && (
|
||||
<div className={styles.phaseTabs}>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||
@@ -306,6 +344,7 @@ export function ComparisonView({
|
||||
Secondary ({secondarySchools.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSchools.length === 0 ? (
|
||||
<EmptyState
|
||||
@@ -318,184 +357,116 @@ export function ComparisonView({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Metric Selector */}
|
||||
<section className={styles.metricSelector}>
|
||||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
||||
Select Metric:
|
||||
</label>
|
||||
<select
|
||||
id="metric-select"
|
||||
value={selectedMetric}
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
className={styles.metricSelect}
|
||||
{/* Sticky school bar — column identity while scrolling. On desktop
|
||||
it shares the sections' grid template (via --school-count) so
|
||||
each chip sits exactly over the column it labels. */}
|
||||
<div
|
||||
className={styles.schoolBar}
|
||||
style={{ '--school-count': activeSchools.length } as CSSProperties}
|
||||
aria-label="Schools in this comparison"
|
||||
>
|
||||
{optgroups.map(({ label, category }) => {
|
||||
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||
if (groupMetrics.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={category} label={label}>
|
||||
{groupMetrics.map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{currentMetricDef?.description && (
|
||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Progress score explanation */}
|
||||
{selectedMetric.includes('progress') && (
|
||||
<p className={styles.progressNote}>
|
||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* School Cards */}
|
||||
<section className={styles.schoolsSection}>
|
||||
<div className={styles.schoolsGrid}>
|
||||
{/* Fills the 200px label rail on desktop (hidden on mobile).
|
||||
All-through schools must not be miscounted as "primary
|
||||
schools"/"secondary schools" — mixed baskets get "· primary
|
||||
view" phrasing instead. */}
|
||||
<div className={styles.barCaption}>
|
||||
<span className={styles.barCaptionEyebrow}>Comparing</span>
|
||||
<span className={styles.barCaptionCount}>
|
||||
{activeSchools.every((sch) =>
|
||||
sch.phase?.toLowerCase().includes(comparePhase),
|
||||
)
|
||||
? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
|
||||
: `${activeSchools.length} schools · ${comparePhase} view`}
|
||||
</span>
|
||||
</div>
|
||||
{activeSchools.map((school, index) => (
|
||||
<div
|
||||
key={school.urn}
|
||||
className={styles.schoolCard}
|
||||
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
||||
className={styles.schoolChip}
|
||||
style={{ borderTopColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
>
|
||||
<span
|
||||
className={styles.chipDot}
|
||||
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={styles.chipText}>
|
||||
<a className={styles.chipName} href={schoolUrl(school.urn, school.school_name)}>
|
||||
<span className={styles.chipNameFull}>{school.school_name}</span>
|
||||
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
|
||||
</a>
|
||||
<span className={styles.chipMeta}>
|
||||
{[
|
||||
/all.?through/i.test(school.phase ?? '') ? 'All-through' : null,
|
||||
school.local_authority,
|
||||
school.school_type,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveSchool(school.urn)}
|
||||
className={styles.removeButton}
|
||||
className={styles.chipRemove}
|
||||
aria-label={`Remove ${school.school_name}`}
|
||||
title="Remove from comparison"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 className={styles.schoolName}>
|
||||
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
||||
</h2>
|
||||
<div className={styles.schoolMeta}>
|
||||
{school.local_authority && (
|
||||
<span className={styles.metaItem}>{school.local_authority}</span>
|
||||
)}
|
||||
{school.school_type && (
|
||||
<span className={styles.metaItem}>{school.school_type}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Latest metric value */}
|
||||
{activeComparisonData[school.urn] && (
|
||||
<div className={styles.latestValue}>
|
||||
<div className={styles.latestLabel}>{metricLabel}</div>
|
||||
{/* Text uses the AA-dark variant; the swatch dot keeps the true series colour */}
|
||||
<div className={styles.latestNumber} style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: CHART_COLORS[index % CHART_COLORS.length],
|
||||
marginRight: '0.4rem',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const yearlyData = activeComparisonData[school.urn].yearly_data;
|
||||
if (yearlyData.length === 0) return '-';
|
||||
|
||||
const latestData = yearlyData[yearlyData.length - 1];
|
||||
const value = latestData[selectedMetric as keyof typeof latestData];
|
||||
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
if (selectedMetric.includes('progress')) {
|
||||
return formatProgress(value as number);
|
||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||
return formatPercentage(value as number);
|
||||
} else {
|
||||
return typeof value === 'number' ? value.toFixed(1) : String(value);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Chart */}
|
||||
{Object.keys(activeComparisonData).length > 0 ? (
|
||||
<section className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
||||
<div className={styles.chartContainer}>
|
||||
<ComparisonChart
|
||||
comparisonData={activeComparisonData}
|
||||
{hasData && (
|
||||
<>
|
||||
<CompareAtAGlance
|
||||
schools={activeSchools}
|
||||
data={activeComparisonData}
|
||||
nationalAverages={nationalAverages}
|
||||
benchmarks={benchmarks}
|
||||
isSecondary={!isPrimary}
|
||||
/>
|
||||
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
|
||||
<CompareAcademics
|
||||
schools={activeSchools}
|
||||
data={activeComparisonData}
|
||||
nationalAverages={nationalAverages}
|
||||
benchmarks={benchmarks}
|
||||
isSecondary={!isPrimary}
|
||||
/>
|
||||
<CompareAdmissions
|
||||
schools={activeSchools}
|
||||
data={activeComparisonData}
|
||||
isSecondary={!isPrimary}
|
||||
/>
|
||||
<CompareCommunity
|
||||
schools={activeSchools}
|
||||
data={activeComparisonData}
|
||||
benchmarks={benchmarks}
|
||||
isSecondary={!isPrimary}
|
||||
/>
|
||||
<TrendsExplorer
|
||||
schools={activeSchools}
|
||||
data={activeComparisonData}
|
||||
metrics={metrics}
|
||||
metric={selectedMetric}
|
||||
metricLabel={metricLabel}
|
||||
onMetricChange={setSelectedMetric}
|
||||
isPrimaryPhase={isPrimary}
|
||||
nationalAverages={nationalAverages}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : activeSchools.length > 0 ? (
|
||||
<section className={styles.chartSection}>
|
||||
<LoadingSkeleton type="list" />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Comparison Table */}
|
||||
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
||||
<section className={styles.tableSection}>
|
||||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.comparisonTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
{activeSchools.map((school) => (
|
||||
<th key={school.urn}>{school.school_name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{years.map((year) => (
|
||||
<tr key={year}>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
||||
{activeSchools.map((school) => {
|
||||
const schoolData = activeComparisonData[school.urn];
|
||||
if (!schoolData) return <td key={school.urn}>-</td>;
|
||||
|
||||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
||||
if (!yearData) return <td key={school.urn}>-</td>;
|
||||
|
||||
const value = yearData[selectedMetric as keyof typeof yearData];
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return <td key={school.urn}>-</td>;
|
||||
}
|
||||
|
||||
let displayValue: string;
|
||||
if (selectedMetric.includes('progress')) {
|
||||
displayValue = formatProgress(value as number);
|
||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||
displayValue = formatPercentage(value as number);
|
||||
} else {
|
||||
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
|
||||
}
|
||||
|
||||
return <td key={school.urn}>{displayValue}</td>;
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<p className={styles.footnote}>
|
||||
Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection
|
||||
outcomes, DfE school admissions data, school census. England averages for test
|
||||
results are official DfE figures; other benchmarks are state-school averages
|
||||
computed from our dataset. Following DfE practice, figures based on 5 or fewer
|
||||
pupils are suppressed and shown as "no data".
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* School Search Modal */}
|
||||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
.row {
|
||||
margin: 1.1rem 0 1.6rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.headNote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.strip {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 15px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.anchorTick {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
width: 2px;
|
||||
height: 26px;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.anchorLabel {
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.point {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pointLabelAbove {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.row {
|
||||
margin: 0.9rem 0 1.3rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.strip {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.point {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* DotStrip — the compare screen's signature element: one measure per strip,
|
||||
* every school's dot on a shared track, anchored by a grey England-average
|
||||
* tick so "right of the tick = above average" needs no domain knowledge.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { stripPositions } from '@/lib/compareLogic';
|
||||
import { CHART_COLORS, CHART_TEXT_COLORS } from '@/lib/utils';
|
||||
import styles from './DotStrip.module.css';
|
||||
|
||||
export interface DotStripProps {
|
||||
label: string;
|
||||
/** One value per school; index = the school's chart-colour index. */
|
||||
values: Array<number | null>;
|
||||
schoolNames: string[];
|
||||
/** Anchor tick, e.g. { value: 62, label: 'England 62%' }. Omit when the
|
||||
* benchmark isn't available — the caller should say why in `headNote`. */
|
||||
anchor?: { value: number; label: string } | null;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
/** Tooltip on the measure label (plain-English definition). */
|
||||
tip?: string;
|
||||
/** Small note on the right of the header row (e.g. the tick legend). */
|
||||
headNote?: string;
|
||||
}
|
||||
|
||||
export function DotStrip({
|
||||
label,
|
||||
values,
|
||||
schoolNames,
|
||||
anchor = null,
|
||||
min = 0,
|
||||
max = 100,
|
||||
unit = '%',
|
||||
tip,
|
||||
headNote,
|
||||
}: DotStripProps) {
|
||||
const points = stripPositions(values, min, max);
|
||||
const span = max - min;
|
||||
const anchorPos =
|
||||
anchor != null
|
||||
? Math.min(100, Math.max(0, ((anchor.value - min) / span) * 100))
|
||||
: null;
|
||||
|
||||
const ariaParts = [
|
||||
anchor ? `${anchor.label}` : null,
|
||||
...points.map(
|
||||
(p) => `${schoolNames[p.schoolIndex] ?? `School ${p.schoolIndex + 1}`} ${p.value}${unit}`,
|
||||
),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.title} title={tip}>
|
||||
{label}
|
||||
</span>
|
||||
{headNote && <span className={styles.headNote}>{headNote}</span>}
|
||||
</div>
|
||||
<div className={styles.strip} role="img" aria-label={`${label}: ${ariaParts.join(', ')}`}>
|
||||
<div className={styles.track} />
|
||||
{anchorPos != null && anchor && (
|
||||
<>
|
||||
<span className={styles.anchorTick} style={{ left: `${anchorPos}%` }} />
|
||||
<span className={styles.anchorLabel} style={{ left: `${anchorPos}%` }}>
|
||||
{anchor.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{points.map((p) => (
|
||||
<span key={p.schoolIndex}>
|
||||
<span
|
||||
className={styles.point}
|
||||
style={{
|
||||
left: `${p.pos}%`,
|
||||
background: CHART_COLORS[p.schoolIndex % CHART_COLORS.length],
|
||||
}}
|
||||
title={`${schoolNames[p.schoolIndex] ?? ''}: ${p.value}${unit}`}
|
||||
/>
|
||||
<span
|
||||
className={`${styles.pointLabel} ${p.labelAbove ? styles.pointLabelAbove : ''}`}
|
||||
style={{
|
||||
left: `${p.pos}%`,
|
||||
color: CHART_TEXT_COLORS[p.schoolIndex % CHART_TEXT_COLORS.length],
|
||||
}}
|
||||
>
|
||||
{p.value}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.moreMeasures {
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.moreMeasures summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
.stripNote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* How children do academically — tier-1 dot strips anchored on official
|
||||
* England averages, tier-2 "More measures" one tap away, equity row against
|
||||
* the computed state-school benchmark. Copy verbatim from the reviewed
|
||||
* mockups; teacher-assessed measures are labelled as such.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { latestValues, verdict } from '@/lib/compareLogic';
|
||||
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||
import { DotStrip } from '@/components/DotStrip';
|
||||
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
import styles from './CompareAcademics.module.css';
|
||||
|
||||
interface StripSpec {
|
||||
label: string;
|
||||
metric: string;
|
||||
anchorKey?: string;
|
||||
tip?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const TIER1_PRIMARY: StripSpec[] = [
|
||||
{
|
||||
label: 'Reading, writing & maths — expected standard',
|
||||
metric: 'rwm_expected_pct',
|
||||
anchorKey: 'rwm_expected_pct',
|
||||
tip: '% of Year 6 pupils reaching the expected standard in reading, writing and maths.',
|
||||
},
|
||||
{ label: 'Reading', metric: 'reading_expected_pct', anchorKey: 'reading_expected_pct' },
|
||||
{
|
||||
label: 'Writing (teacher-assessed)',
|
||||
metric: 'writing_expected_pct',
|
||||
anchorKey: 'writing_expected_pct',
|
||||
tip: 'Writing is assessed by teachers, not tested.',
|
||||
},
|
||||
{ label: 'Maths', metric: 'maths_expected_pct', anchorKey: 'maths_expected_pct' },
|
||||
{
|
||||
label: 'Working at a higher standard than expected',
|
||||
metric: 'rwm_high_pct',
|
||||
anchorKey: 'rwm_high_pct',
|
||||
tip: 'A high score in the reading and maths tests plus “greater depth” in teacher-assessed writing.',
|
||||
},
|
||||
];
|
||||
|
||||
const TIER2_PRIMARY: StripSpec[] = [
|
||||
{
|
||||
label: 'Grammar, punctuation & spelling — expected standard',
|
||||
metric: 'gps_expected_pct',
|
||||
anchorKey: 'gps_expected_pct',
|
||||
},
|
||||
{
|
||||
label: 'Science — expected standard (teacher-assessed)',
|
||||
metric: 'science_expected_pct',
|
||||
anchorKey: 'science_expected_pct',
|
||||
tip: 'Teacher-assessed, like writing — there has been no KS2 science test since 2009, so comparisons are indicative.',
|
||||
},
|
||||
{
|
||||
label: 'Average scaled score — reading',
|
||||
metric: 'reading_avg_score',
|
||||
anchorKey: 'reading_avg_score',
|
||||
min: 100,
|
||||
max: 120,
|
||||
unit: '',
|
||||
},
|
||||
{
|
||||
label: 'Average scaled score — maths',
|
||||
metric: 'maths_avg_score',
|
||||
anchorKey: 'maths_avg_score',
|
||||
min: 100,
|
||||
max: 120,
|
||||
unit: '',
|
||||
},
|
||||
{
|
||||
label: 'Average scaled score — grammar, punctuation & spelling',
|
||||
metric: 'gps_avg_score',
|
||||
anchorKey: 'gps_avg_score',
|
||||
min: 100,
|
||||
max: 120,
|
||||
unit: '',
|
||||
},
|
||||
];
|
||||
|
||||
function Strip({
|
||||
spec,
|
||||
data,
|
||||
urns,
|
||||
schoolNames,
|
||||
national,
|
||||
}: {
|
||||
spec: StripSpec;
|
||||
data: Record<string, ComparisonData>;
|
||||
urns: number[];
|
||||
schoolNames: string[];
|
||||
national: Record<string, number> | undefined;
|
||||
}) {
|
||||
const values = latestValues(data, urns, spec.metric).map((v) =>
|
||||
v != null ? Math.round(v) : null,
|
||||
);
|
||||
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
|
||||
const anchor =
|
||||
anchorValue != null
|
||||
? { value: anchorValue, label: `England ${Math.round(anchorValue)}${spec.unit ?? '%'}` }
|
||||
: null;
|
||||
if (values.every((v) => v == null)) return null;
|
||||
return (
|
||||
<DotStrip
|
||||
label={spec.label}
|
||||
values={values}
|
||||
schoolNames={schoolNames}
|
||||
anchor={anchor}
|
||||
min={spec.min ?? 0}
|
||||
max={spec.max ?? 100}
|
||||
unit={spec.unit ?? '%'}
|
||||
tip={spec.tip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompareAcademics({
|
||||
schools,
|
||||
data,
|
||||
nationalAverages,
|
||||
benchmarks,
|
||||
isSecondary: propIsSecondary,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
nationalAverages?: NationalAverages;
|
||||
benchmarks?: Benchmarks;
|
||||
isSecondary?: boolean;
|
||||
}) {
|
||||
const urns = schools.map((school) => school.urn);
|
||||
const schoolNames = schools.map((school) => school.school_name);
|
||||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||
);
|
||||
|
||||
if (isSecondary) {
|
||||
const att8 = latestValues(data, urns, 'attainment_8_score');
|
||||
const banding = urns.map((urn) => {
|
||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
if (rows[i].progress_8_banding) return rows[i].progress_8_banding as string;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
// DfE stopped publishing Progress 8 from 2024/25: those GCSE year groups
|
||||
// sat no KS2 tests (COVID), so there is no baseline to measure progress
|
||||
// from. A bare "No data" reads as a gap on our side — say why. Judged
|
||||
// PER SCHOOL on its own latest data year: a school whose data simply
|
||||
// stops earlier (an unrelated gap) must not borrow the COVID explanation
|
||||
// from a neighbour that does have 2024/25 data.
|
||||
const p8NotPublished = urns.map((urn) => {
|
||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
|
||||
return y >= 202425;
|
||||
});
|
||||
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
|
||||
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
|
||||
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="How students do academically"
|
||||
how="GCSE results (latest year). Attainment 8 averages performance across eight subjects; Progress 8 shows how much progress students make compared with similar students nationally — the wording is DfE's own banding."
|
||||
>
|
||||
<SectionGrid schools={schools}>
|
||||
<RowLabel tip="Average Attainment 8 score across eight GCSE subjects.">Attainment 8</RowLabel>
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{att8[i] != null ? (
|
||||
<>
|
||||
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>
|
||||
{att8Anchor != null && (
|
||||
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
))}
|
||||
|
||||
<RowLabel tip="DfE's own plain-English Progress 8 label.">Progress 8</RowLabel>
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{banding[i] ? (
|
||||
<Chip
|
||||
tone={
|
||||
/well above|above/i.test(banding[i] as string)
|
||||
? 'good'
|
||||
: /well below|below/i.test(banding[i] as string)
|
||||
? 'warn'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{banding[i]}
|
||||
</Chip>
|
||||
) : p8NotPublished[i] ? (
|
||||
<span className={s.small}>
|
||||
Not published — this GCSE year group sat no KS2 tests (COVID), so DfE has no
|
||||
baseline to measure progress from
|
||||
</span>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
))}
|
||||
|
||||
<RowLabel tip="% achieving grade 5 or above in both English and maths GCSEs.">
|
||||
Grade 5+ in English & maths
|
||||
</RowLabel>
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
))}
|
||||
|
||||
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
))}
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const national = nationalAverages?.primary;
|
||||
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
|
||||
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
||||
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
|
||||
// groups where single pupils move the percentage — show roughly how many
|
||||
// pupils the figure rests on. Taken from the SAME yearly row that supplies
|
||||
// the displayed percentage: resolving eligible_pupils and the
|
||||
// disadvantaged share independently could mix years and misstate the
|
||||
// cohort behind the figure.
|
||||
const cohorts = urns.map((urn) => {
|
||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const row = rows[i];
|
||||
if (row.rwm_expected_disadvantaged_pct != null) {
|
||||
if (row.eligible_pupils == null || row.disadvantaged_pct == null) return null;
|
||||
const cohort = Math.round((row.eligible_pupils * row.disadvantaged_pct) / 100);
|
||||
return cohort > 0 ? cohort : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="How children do academically"
|
||||
how="Results from national tests and teacher assessments at the end of Year 6 — 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."
|
||||
>
|
||||
<div className={s.card}>
|
||||
{TIER1_PRIMARY.map((spec) => (
|
||||
<Strip
|
||||
key={spec.metric}
|
||||
spec={spec}
|
||||
data={data}
|
||||
urns={urns}
|
||||
schoolNames={schoolNames}
|
||||
national={national}
|
||||
/>
|
||||
))}
|
||||
|
||||
<details className={styles.moreMeasures}>
|
||||
<summary>More measures — grammar, punctuation & spelling, science, average scaled scores</summary>
|
||||
{TIER2_PRIMARY.map((spec) => (
|
||||
<Strip
|
||||
key={spec.metric}
|
||||
spec={spec}
|
||||
data={data}
|
||||
urns={urns}
|
||||
schoolNames={schoolNames}
|
||||
national={national}
|
||||
/>
|
||||
))}
|
||||
<p className={styles.stripNote}>
|
||||
The scaled-score strips show the 100–120 window of the full 80–120 range; 100 is the
|
||||
expected standard. Where an England tick is missing, the official figure isn't in
|
||||
our dataset yet.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{disadvantaged.some((v) => v != null) && (
|
||||
<SectionGrid schools={schools}>
|
||||
<RowLabel tip="% of disadvantaged pupils (free school meals in the last 6 years, or looked after by the local authority) reaching the expected standard. Benchmark computed across state schools in our dataset. Based on smaller pupil groups, so a single pupil can move a school's figure noticeably.">
|
||||
Children from lower-income families
|
||||
</RowLabel>
|
||||
{schools.map((school, i) => {
|
||||
const value = disadvantaged[i];
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{value != null ? (
|
||||
<>
|
||||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||
{Math.round(value)}%
|
||||
</span>{' '}
|
||||
{cohorts[i] != null && (
|
||||
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
|
||||
)}{' '}
|
||||
{disadvantagedAnchor != null && (
|
||||
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
|
||||
{verdict(value, disadvantagedAnchor, 5) === 'above' &&
|
||||
`Well above the ${Math.round(disadvantagedAnchor)}% state-school average`}
|
||||
{verdict(value, disadvantagedAnchor, 5) === 'close' &&
|
||||
`Around the ${Math.round(disadvantagedAnchor)}% state-school average`}
|
||||
{verdict(value, disadvantagedAnchor, 5) === 'below' &&
|
||||
`Below the ${Math.round(disadvantagedAnchor)}% state-school average`}
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</SectionGrid>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Getting a place — admissions framed the way the expert review requires:
|
||||
* total applications are "named on N forms" (any preference rank, not
|
||||
* head-to-head), one consistent chip metric (first-preference success),
|
||||
* equal-preference and offers-vs-intake explanations up front.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { admissionsForPhase, summariseAdmissions } from '@/lib/compareLogic';
|
||||
import type { ComparisonData, School } from '@/lib/types';
|
||||
import { CHART_COLORS } from '@/lib/utils';
|
||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
|
||||
export function CompareAdmissions({
|
||||
schools,
|
||||
data,
|
||||
isSecondary = false,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
isSecondary?: boolean;
|
||||
}) {
|
||||
// Admissions rounds are phase-specific: an all-through school's Year 7
|
||||
// round must never stand in for Reception on the primary tab (and vice
|
||||
// versa) — beside pure primaries it reads as Reception odds.
|
||||
const rows = schools.map((school) => admissionsForPhase(data[String(school.urn)], isSecondary));
|
||||
const roundLabel = isSecondary ? 'Year 7' : 'Reception';
|
||||
const anyData = rows.some(Boolean);
|
||||
const entryYear = rows.find(Boolean)?.year;
|
||||
const entryLabel = entryYear
|
||||
? `September ${String(entryYear).slice(0, 4)} entry`
|
||||
: 'the most recent admissions round';
|
||||
|
||||
if (!anyData) {
|
||||
return (
|
||||
<Section
|
||||
title="Getting a place"
|
||||
how={`No ${roundLabel} admissions data is available for these schools yet.`}
|
||||
>
|
||||
<></>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Getting a place"
|
||||
how={
|
||||
<>
|
||||
From the most recent admissions round ({entryLabel}). "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.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SectionGrid schools={schools}>
|
||||
<Measure
|
||||
tip="How many application forms named the school at any preference rank — not the number of families competing head-to-head for a place."
|
||||
label="Interest in the school"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const a = rows[i];
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{a?.total_applications != null && a?.places_offered != null ? (
|
||||
<>
|
||||
Named on <strong>{a.total_applications.toLocaleString('en-GB')}</strong> forms ·{' '}
|
||||
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>
|
||||
We don't hold {roundLabel} admissions data for this school
|
||||
</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
|
||||
</Measure>
|
||||
|
||||
<Measure label="First-choice families offered a place">
|
||||
{schools.map((school, i) => {
|
||||
const summary = summariseAdmissions(rows[i]);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{summary.firstPrefPct != null ? (
|
||||
<>
|
||||
<strong>{summary.firstPrefPct}%</strong>{' '}
|
||||
{summary.chip && summary.chip.tone === 'warn' && (
|
||||
<Chip tone="warn">{summary.chip.text}</Chip>
|
||||
)}
|
||||
<span className={s.barMini}>
|
||||
<i
|
||||
style={{
|
||||
width: `${summary.firstPrefPct}%`,
|
||||
background: CHART_COLORS[i % CHART_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
|
||||
</Measure>
|
||||
|
||||
<Measure label="What this means">
|
||||
{schools.map((school, i) => {
|
||||
const a = rows[i];
|
||||
const summary = summariseAdmissions(a);
|
||||
const info = data[String(school.urn)]?.school_info;
|
||||
const selective = (info?.admissions_policy ?? '').toLowerCase() === 'selective';
|
||||
const faith =
|
||||
!!info?.religious_denomination &&
|
||||
!/^(none|does not apply|not applicable)$/i.test(info.religious_denomination);
|
||||
let text: string | null = null;
|
||||
if (summary.firstPrefPct != null) {
|
||||
if (selective) {
|
||||
// Selective schools: the entrance test decides, whatever the
|
||||
// offer percentage looks like — never the distance template.
|
||||
text =
|
||||
'Entry is by entrance test — the school is selective; distance and preference rank don’t decide places.';
|
||||
} else if (summary.firstPrefPct >= 100) {
|
||||
text = `Every family who put ${school.school_name} first got a place.`;
|
||||
} else if (summary.firstPrefPct >= 90) {
|
||||
text = `Nearly every family who put ${school.school_name} first got a place.`;
|
||||
} else if (a?.oversubscribed) {
|
||||
text = isSecondary
|
||||
? faith
|
||||
? 'More first-choice applications than places — check the school’s admission criteria (faith-based criteria may apply).'
|
||||
: 'More first-choice applications than places — check the school’s admission criteria (catchment or distance often decides, but criteria vary).'
|
||||
: 'More first-choice applications than places — check the school’s admission criteria (for most non-faith primaries, distance decides).';
|
||||
} else {
|
||||
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{text ? <span className={s.small}>{text}</span> : <span className={s.small}>—</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* At a glance — the short version of every section below it. Copy verbatim
|
||||
* from the reviewed mockups. Report-card cells summarise by counting graded
|
||||
* areas (best first) and always NAME problem areas; safeguarding is a
|
||||
* separate line, never a count.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
admissionsForPhase,
|
||||
latestValues,
|
||||
ofstedDisplay,
|
||||
summariseAdmissions,
|
||||
verdict,
|
||||
type ReportCardSummary,
|
||||
} from '@/lib/compareLogic';
|
||||
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
|
||||
function ReportCardChips({ summary }: { summary: ReportCardSummary }) {
|
||||
return (
|
||||
<>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Report card</strong>
|
||||
<span className={s.chipStack}>
|
||||
{summary.counts.map((c) => (
|
||||
<Chip key={c.label} tone={c.label === 'Expected standard' ? 'neutral' : 'good'}>
|
||||
{c.count} area{c.count === 1 ? '' : 's'} {c.label}
|
||||
</Chip>
|
||||
))}
|
||||
{summary.problems.map((p) => (
|
||||
<Chip key={p.areaLabel} tone={p.label === 'Urgent improvement' ? 'bad' : 'warn'}>
|
||||
{p.areaLabel}: {p.label}
|
||||
</Chip>
|
||||
))}
|
||||
</span>
|
||||
<span className={s.small}>
|
||||
{summary.allClear && 'No areas need attention · '}
|
||||
{summary.safeguarding === 'met' && 'Safeguarding met'}
|
||||
{summary.safeguarding === 'not_met' && 'Safeguarding not met'}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompareAtAGlance({
|
||||
schools,
|
||||
data,
|
||||
nationalAverages,
|
||||
benchmarks,
|
||||
isSecondary: propIsSecondary,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
nationalAverages?: NationalAverages;
|
||||
benchmarks?: Benchmarks;
|
||||
isSecondary?: boolean;
|
||||
}) {
|
||||
const urns = schools.map((school) => school.urn);
|
||||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||
);
|
||||
const headlineKey = isSecondary ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
const headlineValues = latestValues(data, urns, headlineKey);
|
||||
const anchor = isSecondary
|
||||
? nationalAverages?.secondary?.attainment_8_score
|
||||
: nationalAverages?.primary?.rwm_expected_pct;
|
||||
const medianPupils = isSecondary
|
||||
? benchmarks?.secondary?.median_pupils
|
||||
: benchmarks?.primary?.median_pupils;
|
||||
|
||||
return (
|
||||
<Section title="At a glance" how="The short version — each row below is explained in its own section further down.">
|
||||
<SectionGrid schools={schools}>
|
||||
<Measure label="Latest Ofsted inspection">
|
||||
{schools.map((school, i) => {
|
||||
const display = ofstedDisplay(data[String(school.urn)]?.ofsted);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{display.kind === 'report_card' && <ReportCardChips summary={display.summary} />}
|
||||
{(display.kind === 'graded' || display.kind === 'carried_forward') && (
|
||||
<>
|
||||
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
|
||||
{display.gradeLabel}
|
||||
</span>
|
||||
{display.carriedForward && <span className={s.small}>Grade carried forward</span>}
|
||||
</>
|
||||
)}
|
||||
{display.kind === 'transitional' && (
|
||||
<>
|
||||
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
|
||||
No overall grade
|
||||
</span>
|
||||
<span className={s.small}>Sub-judgements only</span>
|
||||
</>
|
||||
)}
|
||||
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip={
|
||||
isSecondary
|
||||
? 'Average Attainment 8 score across GCSE subjects (latest year).'
|
||||
: '% of Year 6 pupils reaching the expected standard in reading, writing and maths (latest year).'
|
||||
}
|
||||
label={isSecondary ? 'Attainment 8 score' : 'Children reaching the expected standard'}
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const value = headlineValues[i];
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{value != null ? (
|
||||
<>
|
||||
<span className={s.big}>{isSecondary ? value.toFixed(1) : `${Math.round(value)}%`}</span>{' '}
|
||||
{anchor != null && (
|
||||
<Chip
|
||||
tone={
|
||||
verdict(value, anchor) === 'above'
|
||||
? 'good'
|
||||
: verdict(value, anchor) === 'below'
|
||||
? 'warn'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{verdict(value, anchor) === 'above' && 'Above England average'}
|
||||
{verdict(value, anchor) === 'close' && 'Close to England average'}
|
||||
{verdict(value, anchor) === 'below' && 'Below England average'}
|
||||
</Chip>
|
||||
)}
|
||||
{anchor != null && (
|
||||
<span className={s.small}>
|
||||
England average {isSecondary ? anchor.toFixed(1) : `${Math.round(anchor)}%`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Getting a place">
|
||||
{schools.map((school, i) => {
|
||||
// Phase-matched round only — an all-through school's Year 7 round
|
||||
// must not masquerade as Reception odds on the primary tab.
|
||||
const summary = summariseAdmissions(
|
||||
admissionsForPhase(data[String(school.urn)], isSecondary),
|
||||
);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{summary.chip ? (
|
||||
<>
|
||||
<Chip tone={summary.chip.tone}>{summary.chip.text}</Chip>
|
||||
{summary.interest && <span className={s.small}>{summary.interest}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No admissions data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Size">
|
||||
{schools.map((school, i) => {
|
||||
const census = data[String(school.urn)]?.census;
|
||||
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
|
||||
// An all-through school's roll covers every age group, so judging
|
||||
// it against the single-phase median ("Much larger than average")
|
||||
// is meaningless — label the roll honestly instead.
|
||||
const isAllThrough = /all.?through/i.test(school.phase ?? '');
|
||||
let sizeNote: string | null = null;
|
||||
if (isAllThrough) {
|
||||
sizeNote = 'Whole-school roll (all-through, all ages)';
|
||||
} else if (pupils != null && medianPupils != null) {
|
||||
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
|
||||
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
|
||||
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
|
||||
else if (pupils <= medianPupils * 0.9) sizeNote = 'Smaller than average';
|
||||
else sizeNote = 'About average size';
|
||||
}
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{pupils != null ? (
|
||||
<>
|
||||
{pupils.toLocaleString('en-GB')} pupils
|
||||
{sizeNote && <span className={s.small}>{sizeNote}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Who goes there — the school's community from the latest census plus GIAS
|
||||
* facts. Benchmark chips use the computed state-school averages and must
|
||||
* carry their provenance wording (never "England average" for computed
|
||||
* figures). Copy verbatim from the reviewed mockups.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { verdict } from '@/lib/compareLogic';
|
||||
import type { Benchmarks, ComparisonData, School } from '@/lib/types';
|
||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
|
||||
function pctSplit(part: number | null | undefined, total: number | null | undefined): string | null {
|
||||
if (part == null || total == null || total === 0) return null;
|
||||
return `${Math.round((part / total) * 100)}%`;
|
||||
}
|
||||
|
||||
export function CompareCommunity({
|
||||
schools,
|
||||
data,
|
||||
benchmarks,
|
||||
isSecondary: propIsSecondary,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
benchmarks?: Benchmarks;
|
||||
isSecondary?: boolean;
|
||||
}) {
|
||||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||
);
|
||||
const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary;
|
||||
|
||||
const fsmChip = (value: number | null) => {
|
||||
// FSM is anchored only against a real FSM benchmark (census-sourced,
|
||||
// pupil-weighted). disadvantaged_pct is a different measure (FSM6+CLA)
|
||||
// — never fall back across definitions; no anchor means no chip.
|
||||
const anchor = bench?.fsm_pct ?? null;
|
||||
if (value == null || anchor == null) return null;
|
||||
const v = verdict(value, anchor, 3);
|
||||
return (
|
||||
<Chip tone="neutral">
|
||||
{v === 'above' && `Above the state-school average (${Math.round(anchor)}%)`}
|
||||
{v === 'close' && `About the state-school average (${Math.round(anchor)}%)`}
|
||||
{v === 'below' && `Below the state-school average (${Math.round(anchor)}%)`}
|
||||
</Chip>
|
||||
);
|
||||
};
|
||||
|
||||
const anyAllThrough = schools.some((school) => /all.?through/i.test(school.phase ?? ''));
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Who goes there"
|
||||
how={
|
||||
<>
|
||||
The school's community, from the latest school census. State-school averages are
|
||||
computed from our dataset and shown for context — there's no “right”
|
||||
number here.
|
||||
{anyAllThrough && (
|
||||
<>
|
||||
{' '}
|
||||
For all-through schools these figures cover the whole school, all ages — not just
|
||||
the {isSecondary ? 'secondary' : 'primary'} phase.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SectionGrid schools={schools}>
|
||||
<Measure label="Pupils on roll">
|
||||
{schools.map((school, i) => {
|
||||
const info = data[String(school.urn)]?.school_info as (School & { gias_total_pupils?: number | null; capacity?: number | null }) | undefined;
|
||||
const census = data[String(school.urn)]?.census;
|
||||
const pupils = census?.total_pupils ?? info?.gias_total_pupils ?? null;
|
||||
const capacity = info?.capacity ?? null;
|
||||
let capNote: string | null = null;
|
||||
if (pupils != null && capacity != null && capacity > 0) {
|
||||
capNote =
|
||||
pupils >= capacity
|
||||
? `${capacity.toLocaleString('en-GB')} places — at or above capacity`
|
||||
: `of ${capacity.toLocaleString('en-GB')} places (${Math.round((pupils / capacity) * 100)}% full)`;
|
||||
}
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{pupils != null ? (
|
||||
<>
|
||||
{pupils.toLocaleString('en-GB')}
|
||||
{capNote && <span className={s.small}>{capNote}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Girls / boys">
|
||||
{schools.map((school, i) => {
|
||||
const census = data[String(school.urn)]?.census;
|
||||
const girls = pctSplit(census?.female_pupils, census?.total_pupils);
|
||||
const boys = pctSplit(census?.male_pupils, census?.total_pupils);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{girls && boys ? `${girls} / ${boys}` : <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="% of pupils eligible for free school meals — a common measure of how many pupils come from lower-income families. Benchmark computed across state schools in our dataset."
|
||||
label="Free school meals"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const fsm = data[String(school.urn)]?.census?.fsm_pct ?? null;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{fsm != null ? (
|
||||
<>
|
||||
{Math.round(fsm)}% {fsmChip(fsm)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="% of pupils whose first language is known or believed to be other than English. State-school average computed from our dataset."
|
||||
label="English as an additional language"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const eal = data[String(school.urn)]?.census?.eal_pct ?? null;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{eal != null ? `${Math.round(eal)}%` : <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="% of pupils receiving SEN support (not including EHC plans). A high figure can mean the school hosts specialist provision — often a strength, not a warning sign. State-school average computed from our dataset."
|
||||
label="Extra learning support (SEN)"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const rows = data[String(school.urn)]?.yearly_data ?? [];
|
||||
let sen: number | null = null;
|
||||
for (let r = rows.length - 1; r >= 0; r--) {
|
||||
if (rows[r].sen_support_pct != null) {
|
||||
sen = rows[r].sen_support_pct;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const high =
|
||||
sen != null && bench?.sen_support_pct != null && sen >= bench.sen_support_pct * 1.75;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{sen != null ? (
|
||||
<>
|
||||
{Math.round(sen)}% {high && <Chip tone="neutral">Well above average</Chip>}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Faith character">
|
||||
{schools.map((school, i) => {
|
||||
const info = data[String(school.urn)]?.school_info;
|
||||
const faith = info?.religious_denomination;
|
||||
const none = !faith || faith === 'Does not apply' || faith === 'None';
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{none ? 'None' : faith}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Ages">
|
||||
{schools.map((school, i) => {
|
||||
const info = data[String(school.urn)]?.school_info;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{info?.age_range || <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Run by">
|
||||
{schools.map((school, i) => {
|
||||
const info = data[String(school.urn)]?.school_info;
|
||||
const trust = info?.trust_name;
|
||||
const la = info?.local_authority ?? school.local_authority;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{trust ? trust : la ? `${la} council` : <span className={s.small}>No data</span>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Ofsted section — one visual grammar for inspection detail across all
|
||||
* three regimes (legacy graded, interim carried-forward, renewed-framework
|
||||
* report card). Copy comes verbatim from the reviewed mockups.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
OFSTED_LEGACY_GRADES,
|
||||
ofstedDisplay,
|
||||
rcAreaLabel,
|
||||
type OfstedDisplay,
|
||||
} from '@/lib/compareLogic';
|
||||
import type { ComparisonData, OfstedInspection, School } from '@/lib/types';
|
||||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
|
||||
const GRADE_TONE: Record<number, 'good' | 'warn' | 'bad'> = {
|
||||
1: 'good',
|
||||
2: 'good',
|
||||
3: 'warn',
|
||||
4: 'bad',
|
||||
};
|
||||
|
||||
const RC_CODE_TONE = (code: number): 'good' | 'warn' | 'bad' | 'neutral' =>
|
||||
code <= 2 ? 'good' : code === 3 ? 'neutral' : code === 4 ? 'warn' : 'bad';
|
||||
|
||||
function formatInspectionDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function yearsSince(iso: string | null): number | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
|
||||
}
|
||||
|
||||
function ResultCell({ display }: { display: OfstedDisplay }) {
|
||||
if (display.kind === 'none') {
|
||||
return <span className={s.small}>No inspection outcome in our dataset</span>;
|
||||
}
|
||||
if (display.kind === 'report_card') {
|
||||
return (
|
||||
<>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Report card</strong>
|
||||
<span className={s.small}>New-style inspection — no overall grade is given</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (display.kind === 'transitional') {
|
||||
return (
|
||||
<>
|
||||
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
|
||||
No overall grade
|
||||
</span>
|
||||
<span className={s.small}>
|
||||
Inspected under transitional framework (sub-judgements only)
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
|
||||
{display.gradeLabel}
|
||||
</span>
|
||||
<span className={s.small}>
|
||||
{display.carriedForward
|
||||
? 'Grade carried forward from an earlier inspection (ungraded visit since)'
|
||||
: 'Overall grade (older-style inspection)'}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JudgementDetailCell({
|
||||
ofsted,
|
||||
display,
|
||||
schoolName,
|
||||
}: {
|
||||
ofsted: OfstedInspection;
|
||||
display: OfstedDisplay;
|
||||
schoolName: string;
|
||||
}) {
|
||||
if (display.kind === 'report_card') {
|
||||
const entries = Object.entries(ofsted.report_card ?? {});
|
||||
return (
|
||||
<div className={s.rcList}>
|
||||
{entries.map(([key, entry]) => (
|
||||
<div key={key} className={s.rcRow}>
|
||||
<span className={s.rcArea}>{rcAreaLabel(key)}</span>
|
||||
<Chip tone={RC_CODE_TONE(entry.code)}>{entry.label}</Chip>
|
||||
</div>
|
||||
))}
|
||||
{ofsted.rc_safeguarding_met != null && (
|
||||
<div className={s.rcRow}>
|
||||
<span className={s.rcArea}>Safeguarding</span>
|
||||
<Chip tone={ofsted.rc_safeguarding_met ? 'good' : 'bad'}>
|
||||
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const legacyAreas: Array<[string, number | null | undefined]> = [
|
||||
['Quality of education', ofsted.quality_of_education],
|
||||
['Behaviour & attitudes', ofsted.behaviour_attitudes],
|
||||
['Personal development', ofsted.personal_development],
|
||||
['Leadership & management', ofsted.leadership_management],
|
||||
['Early years provision', ofsted.early_years_provision],
|
||||
['Sixth form provision', ofsted.sixth_form_provision],
|
||||
];
|
||||
// Only real Ofsted grades (1–4) are judgements. The MI file uses sentinel
|
||||
// codes for "not applicable / no judgement" (9, and 0/8 variants) — those
|
||||
// must never render as a rating chip.
|
||||
const published = legacyAreas.filter(
|
||||
(entry): entry is [string, number] =>
|
||||
entry[1] != null && entry[1] >= 1 && entry[1] <= 4,
|
||||
);
|
||||
|
||||
if (published.length === 0) {
|
||||
return (
|
||||
<span className={s.small}>
|
||||
We don't hold area-by-area detail for this inspection — see {schoolName}'s
|
||||
Ofsted page for the full report.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={s.rcList}>
|
||||
{published.map(([label, grade]) => (
|
||||
<div key={label} className={s.rcRow}>
|
||||
<span className={s.rcArea}>{label}</span>
|
||||
<Chip tone={GRADE_TONE[grade as number] ?? 'neutral'}>
|
||||
{OFSTED_LEGACY_GRADES[grade as number] ?? String(grade)}
|
||||
</Chip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompareOfsted({
|
||||
schools,
|
||||
data,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
}) {
|
||||
const displays = schools.map((school) => ofstedDisplay(data[String(school.urn)]?.ofsted));
|
||||
const kinds = new Set(displays.map((d) => d.kind).filter((k) => k !== 'none'));
|
||||
const mixedRegimes = kinds.size > 1;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Ofsted inspection"
|
||||
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.
|
||||
{mixedRegimes && (
|
||||
<> A report card and an older overall grade aren't directly comparable.</>
|
||||
)}{' '}
|
||||
(Ofsted's "Expected standard" rating is unrelated to the KS2 "expected
|
||||
standard" test measure further down this page.)
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SectionGrid schools={schools}>
|
||||
<Measure label="Result">
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
<ResultCell display={displays[i]} />
|
||||
</Cell>
|
||||
))}
|
||||
</Measure>
|
||||
|
||||
<Measure label="Inspected">
|
||||
{schools.map((school, i) => {
|
||||
const ofsted = data[String(school.urn)]?.ofsted;
|
||||
// A report card is dated by its OWN inspection date. The legacy
|
||||
// inspection_date belongs to an older inspection and must never
|
||||
// be shown against a report card (report cards exist only from
|
||||
// Nov 2025).
|
||||
const dateIso =
|
||||
displays[i].kind === 'report_card'
|
||||
? ofsted?.rc_inspection_date ?? null
|
||||
: ofsted?.inspection_date ?? null;
|
||||
const age = yearsSince(dateIso);
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{formatInspectionDate(dateIso)}{' '}
|
||||
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="Older-style inspections: one rating per judgement area, where published. New-style inspections: the full report card, one rating per area of school life."
|
||||
label="Judgement detail"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const ofsted = data[String(school.urn)]?.ofsted;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{ofsted ? (
|
||||
<JudgementDetailCell
|
||||
ofsted={ofsted}
|
||||
display={displays[i]}
|
||||
schoolName={school.school_name}
|
||||
/>
|
||||
) : (
|
||||
<span className={s.small}>No inspection in our dataset</span>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
|
||||
</Measure>
|
||||
|
||||
<Measure
|
||||
tip="Links to the school's page on ofsted.gov.uk, where all its inspection reports are listed."
|
||||
label="Ofsted page"
|
||||
>
|
||||
{schools.map((school, i) => {
|
||||
const url =
|
||||
data[String(school.urn)]?.ofsted?.ofsted_page_url ??
|
||||
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer">
|
||||
{school.school_name}'s Ofsted page →
|
||||
</a>
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</Measure>
|
||||
</SectionGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.explore {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.explore summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark);
|
||||
padding: 0.85rem 1.1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.explore[open] summary {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.inner {
|
||||
border: 1px solid var(--border-light);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--bg-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.picker label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.picker select {
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progressNote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* ComparisonChart owns its own canvas height now (a definite px value per
|
||||
breakpoint), with the mobile chip legend above and the gap note below it
|
||||
flowing at natural size. This box therefore only needs to not constrain
|
||||
that height — no fixed height, or the note would again eat the plot. */
|
||||
.chartBox {
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Explore trends — the full grouped metric catalogue (nothing from the old
|
||||
* compare page is lost; spec §4's tier 3) driving the year-by-year chart with
|
||||
* its England reference line. Matches the mockup: a measure picker and the
|
||||
* chart only (no data table).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import type { ComparisonData, MetricDefinition, NationalAverages, School } from '@/lib/types';
|
||||
import { track } from '@/lib/analytics';
|
||||
import { Section } from './sectionShared';
|
||||
import styles from './TrendsExplorer.module.css';
|
||||
|
||||
const ComparisonChart = dynamic(
|
||||
() => import('../ComparisonChart').then((m) => m.ComparisonChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'Expected Standard', category: 'expected' },
|
||||
{ label: 'Higher Standard', category: 'higher' },
|
||||
{ label: 'Progress Scores', category: 'progress' },
|
||||
{ label: 'Average Scores', category: 'average' },
|
||||
{ label: 'Gender Performance', category: 'gender' },
|
||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||
{ label: 'School Context', category: 'context' },
|
||||
{ label: 'Absence', category: 'absence' },
|
||||
{ label: '3-Year Trends', category: 'trends' },
|
||||
];
|
||||
|
||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'GCSE Performance', category: 'gcse' },
|
||||
];
|
||||
|
||||
export const PRIMARY_CATEGORIES = PRIMARY_OPTGROUPS.map((g) => g.category);
|
||||
export const SECONDARY_CATEGORIES = SECONDARY_OPTGROUPS.map((g) => g.category);
|
||||
|
||||
export function TrendsExplorer({
|
||||
schools,
|
||||
data,
|
||||
metrics,
|
||||
metric,
|
||||
onMetricChange,
|
||||
isPrimaryPhase,
|
||||
nationalAverages,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
metrics: MetricDefinition[];
|
||||
/** Controlled: the page owns the metric so the URL contract survives. */
|
||||
metric: string;
|
||||
onMetricChange: (metric: string) => void;
|
||||
isPrimaryPhase: boolean;
|
||||
nationalAverages?: NationalAverages;
|
||||
}) {
|
||||
const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||
const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||
const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category));
|
||||
const metricDef = metrics.find((m) => m.key === metric);
|
||||
const metricLabel = metricDef?.label || metric;
|
||||
|
||||
const nationalByYear: Record<number, number | null> = {};
|
||||
for (const entry of nationalAverages?.by_year ?? []) {
|
||||
const block = isPrimaryPhase ? entry.primary : entry.secondary;
|
||||
nationalByYear[entry.year] = block?.[metric] ?? null;
|
||||
}
|
||||
|
||||
const handleMetricChange = (next: string) => {
|
||||
track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' });
|
||||
onMetricChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Explore trends"
|
||||
how="The full year-by-year explorer for any measure, with the England average as a dashed reference line where official figures exist."
|
||||
>
|
||||
<details className={styles.explore} open>
|
||||
<summary>Year-by-year trends</summary>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.picker}>
|
||||
<label htmlFor="trends-metric-select">Measure:</label>
|
||||
<select
|
||||
id="trends-metric-select"
|
||||
value={metric}
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
>
|
||||
{optgroups.map(({ label, category }) => {
|
||||
const group = filteredMetrics.filter((m) => m.category === category);
|
||||
if (group.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={category} label={label}>
|
||||
{group.map((m) => (
|
||||
<option key={m.key} value={m.key}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{metricDef?.description && <span className={styles.desc}>{metricDef.description}</span>}
|
||||
</div>
|
||||
|
||||
{metric.includes('progress') && (
|
||||
<p className={styles.progressNote}>
|
||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the
|
||||
national average. DfE stopped publishing KS2 progress after 2022/23 (no KS1 baseline).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.chartBox}>
|
||||
<ComparisonChart
|
||||
comparisonData={data}
|
||||
schools={schools}
|
||||
metric={metric}
|
||||
metricLabel={metricLabel}
|
||||
nationalByYear={nationalByYear}
|
||||
isSecondary={!isPrimaryPhase}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/* Shared layout for the compare screen's measure-first sections.
|
||||
Mobile base: each row-label becomes a measure header and each school cell
|
||||
stacks under it (colour-coded via the cell's ::before school tag).
|
||||
Desktop (≥761px): the mockups' grid — 200px row-label column + one column
|
||||
per school (2–4 columns supported via --school-count). */
|
||||
|
||||
.section {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
.how {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.35rem 0 0 0.95rem;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
/* Mobile base: each measure is a card; each cell is a school row led by a
|
||||
colour dot + short name. `display: contents` at ≥761px dissolves the card
|
||||
back into the shared grid. */
|
||||
.measure {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 0.75rem 0.85rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0 0.1rem;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* The school name gets its own full-width line above the value — real
|
||||
school names are long and varied, so a fixed-width name column truncated
|
||||
them ("Our Lady Queen of H…") or crowded the value. */
|
||||
.cellTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--sc, var(--text-secondary));
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.cellDot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot, var(--text-muted));
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.small {
|
||||
display: block;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chipGood {
|
||||
background: rgba(45, 125, 125, 0.14);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.chipWarn {
|
||||
background: var(--accent-gold-bg);
|
||||
color: var(--accent-gold-text);
|
||||
}
|
||||
|
||||
.chipBad {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
.chipNeutral {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.help {
|
||||
display: inline-flex;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.65rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: help;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.badgeGood {
|
||||
background: rgba(45, 125, 125, 0.14);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.badgeWarn {
|
||||
background: var(--accent-gold-bg);
|
||||
color: var(--accent-gold-text);
|
||||
}
|
||||
|
||||
.badgeBad {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
.rcList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.rcRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.rcArea {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chipStack {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
flex-basis: 100%;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.barMini {
|
||||
display: block;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
margin-top: 0.3rem;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.barMini > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--accent-coral-dark);
|
||||
}
|
||||
|
||||
@media (min-width: 761px) {
|
||||
.grid {
|
||||
grid-template-columns: 200px repeat(var(--school-count, 3), 1fr);
|
||||
gap: 0 0.75rem;
|
||||
}
|
||||
|
||||
/* Dissolve the per-measure card so its label + cells become grid items of
|
||||
.grid, keeping columns aligned across every measure. */
|
||||
.measure {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.cellTag {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
color: var(--text-secondary);
|
||||
padding: 0.85rem 0.5rem 0.85rem 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: block;
|
||||
padding: 0.85rem 0.25rem;
|
||||
border-top: none;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
flex-basis: auto;
|
||||
padding-left: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Small shared pieces for the compare sections: the section shell, the
|
||||
* row-label + per-school-cell grid, and tone-mapped chips. Copy passed into
|
||||
* these comes verbatim from the reviewed mockups
|
||||
* (docs/superpowers/specs/mockups/) — do not paraphrase it here.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
|
||||
import styles from './compareSections.module.css';
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
how,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
how?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
{how && <p className={styles.how}>{how}</p>}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionGrid({
|
||||
schools,
|
||||
children,
|
||||
}: {
|
||||
schools: School[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={styles.grid}
|
||||
style={{ '--school-count': schools.length } as CSSProperties}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RowLabel({ children, tip }: { children: ReactNode; tip?: string }) {
|
||||
return (
|
||||
<div className={styles.rowLabel}>
|
||||
{children}
|
||||
{tip && (
|
||||
<span className={styles.help} title={tip} aria-label={tip}>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One measure = its row label plus a cell per school. `display: contents` on
|
||||
* desktop (see CSS) makes these flow into the section grid as if this wrapper
|
||||
* weren't here, keeping columns aligned across measures; on mobile the wrapper
|
||||
* becomes a card so each measure reads as its own block.
|
||||
*/
|
||||
export function Measure({
|
||||
label,
|
||||
tip,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
tip?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.measure}>
|
||||
<RowLabel tip={tip}>{label}</RowLabel>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Cell({
|
||||
school,
|
||||
index,
|
||||
children,
|
||||
}: {
|
||||
school: School;
|
||||
index: number;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={styles.cell}
|
||||
style={
|
||||
{
|
||||
'--sc': CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length],
|
||||
'--dot': CHART_COLORS[index % CHART_COLORS.length],
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Mobile-only per-school tag (dot + short name); hidden on desktop,
|
||||
where the column header identifies the school. */}
|
||||
<span className={styles.cellTag}>
|
||||
<span className={styles.cellDot} aria-hidden="true" />
|
||||
{shortName(school.school_name)}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ChipTone = 'good' | 'warn' | 'bad' | 'neutral';
|
||||
|
||||
const CHIP_TONE_CLASS: Record<ChipTone, string> = {
|
||||
good: styles.chipGood,
|
||||
warn: styles.chipWarn,
|
||||
bad: styles.chipBad,
|
||||
neutral: styles.chipNeutral,
|
||||
};
|
||||
|
||||
export function Chip({ tone, children }: { tone: ChipTone; children: ReactNode }) {
|
||||
return <span className={`${styles.chip} ${CHIP_TONE_CLASS[tone]}`}>{children}</span>;
|
||||
}
|
||||
|
||||
export const sectionStyles = styles;
|
||||
@@ -1,50 +1,18 @@
|
||||
/**
|
||||
* Custom hook for managing school comparison state
|
||||
* Uses shared context for real-time updates across components
|
||||
* Custom hook for managing school comparison state.
|
||||
*
|
||||
* This hook is mounted on every page via the global Navigation and
|
||||
* ComparisonToast, so it must stay cheap — it exposes basket state only.
|
||||
* The compare page fetches `/api/compare` itself (ComparisonView); nothing
|
||||
* ever read the comparison payload from here, so the previous per-page SWR
|
||||
* fetch (which fired on every page whenever the basket was non-empty) was
|
||||
* dead weight and has been removed.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { fetcher } from '@/lib/api';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import type { ComparisonResponse } from '@/lib/types';
|
||||
|
||||
export function useComparison() {
|
||||
const {
|
||||
selectedSchools,
|
||||
addSchool,
|
||||
removeSchool,
|
||||
replaceSchools,
|
||||
clearAll,
|
||||
isSelected,
|
||||
canAddMore,
|
||||
isInitialized,
|
||||
} = useComparisonContext();
|
||||
|
||||
// Fetch comparison data for selected schools
|
||||
const urns = selectedSchools.map((s) => s.urn).join(',');
|
||||
const { data, error, isLoading, mutate } = useSWR<ComparisonResponse>(
|
||||
selectedSchools.length > 0 ? `/compare?urns=${urns}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
selectedSchools,
|
||||
comparisonData: data?.comparison,
|
||||
isLoading,
|
||||
error,
|
||||
addSchool,
|
||||
removeSchool,
|
||||
replaceSchools,
|
||||
clearAll,
|
||||
isSelected,
|
||||
canAddMore,
|
||||
isInitialized,
|
||||
mutate,
|
||||
};
|
||||
return useComparisonContext();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Pure series-building for the comparison trend chart, extracted from
|
||||
* ComparisonChart so it is unit-testable without a canvas.
|
||||
*
|
||||
* Chart truthfulness rules (spec §8.1): every academic year between the
|
||||
* first and last data point appears on the axis — cancelled test years
|
||||
* (2019/20, 2020/21) and the unpublished 2021/22 school-level year render
|
||||
* as real gaps, never as compressed time; school lines never bridge gaps.
|
||||
*/
|
||||
|
||||
import type { ComparisonData } from './types';
|
||||
|
||||
/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */
|
||||
function nextAcademicYear(year: number): number {
|
||||
const start = Math.floor(year / 100);
|
||||
const end = year % 100;
|
||||
return (start + 1) * 100 + (end + 1);
|
||||
}
|
||||
|
||||
/** Every academic year from min(years) to max(years), inclusive. */
|
||||
export function fillAcademicYears(years: number[]): number[] {
|
||||
if (years.length === 0) return [];
|
||||
const ints = [...new Set(years.map((y) => Math.trunc(y)))].sort((a, b) => a - b);
|
||||
const out: number[] = [];
|
||||
let y = ints[0];
|
||||
const last = ints[ints.length - 1];
|
||||
while (y <= last && out.length < 50) {
|
||||
out.push(y);
|
||||
y = nextAcademicYear(y);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CompareChartSeries {
|
||||
label: string;
|
||||
data: Array<number | null>;
|
||||
/** Index into CHART_COLORS / point styles. */
|
||||
schoolIndex: number;
|
||||
spanGaps: false;
|
||||
}
|
||||
|
||||
export interface EnglandSeries {
|
||||
label: 'England average';
|
||||
data: Array<number | null>;
|
||||
borderDash: [number, number];
|
||||
spanGaps: false;
|
||||
}
|
||||
|
||||
export interface CompareChart {
|
||||
years: number[];
|
||||
schoolDatasets: CompareChartSeries[];
|
||||
englandDataset: EnglandSeries | null;
|
||||
/** True when England published a 2021/22 figure but no school has one —
|
||||
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */
|
||||
showUnpublished202122Note: boolean;
|
||||
/** Years where the England overlay has a value but no school does — the
|
||||
* chart shows a dashed-line-only stretch that needs explaining (KS2 and
|
||||
* KS4 have different honest explanations, so the component owns the copy). */
|
||||
englandOnlyYears: number[];
|
||||
}
|
||||
|
||||
export function buildCompareChart(
|
||||
comparisonData: Record<string, ComparisonData>,
|
||||
schools: Array<{ urn: number; school_name: string }>,
|
||||
metric: string,
|
||||
nationalByYear?: Record<number, number | null | undefined>,
|
||||
): CompareChart {
|
||||
const rawYears = schools.flatMap(
|
||||
(s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [],
|
||||
);
|
||||
const years = fillAcademicYears(rawYears);
|
||||
|
||||
const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => {
|
||||
const rows = comparisonData[String(school.urn)]?.yearly_data ?? [];
|
||||
const byYear = new Map<number, Record<string, unknown>>();
|
||||
for (const row of rows) byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
|
||||
return {
|
||||
label: school.school_name,
|
||||
data: years.map((year) => {
|
||||
const v = byYear.get(year)?.[metric];
|
||||
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
|
||||
}),
|
||||
schoolIndex,
|
||||
spanGaps: false,
|
||||
};
|
||||
});
|
||||
|
||||
let englandDataset: EnglandSeries | null = null;
|
||||
if (nationalByYear) {
|
||||
const data = years.map((year) => {
|
||||
const v = nationalByYear[year];
|
||||
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
|
||||
});
|
||||
if (data.some((v) => v != null)) {
|
||||
englandDataset = { label: 'England average', data, borderDash: [5, 4], spanGaps: false };
|
||||
}
|
||||
}
|
||||
|
||||
const englandOnlyYears = years.filter(
|
||||
(year, i) =>
|
||||
englandDataset?.data[i] != null && schoolDatasets.every((ds) => ds.data[i] == null),
|
||||
);
|
||||
|
||||
const showUnpublished202122Note = englandOnlyYears.includes(202122);
|
||||
|
||||
return { years, schoolDatasets, englandDataset, showUnpublished202122Note, englandOnlyYears };
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Comprehension rules for the compare screen, kept pure and unit-tested.
|
||||
*
|
||||
* These encode the expert-review requirements (spec §8 of the compare
|
||||
* redesign): report-card summaries count graded areas only (safeguarding is
|
||||
* a separate binary judgement), problem areas are always NAMED rather than
|
||||
* folded into counts, grade labels pass through from the API (live-sampled
|
||||
* Ofsted vocabulary — never invented here), admissions chips use one
|
||||
* consistent metric, and progress bands follow DfE's confidence-interval
|
||||
* methodology instead of thresholding point estimates.
|
||||
*/
|
||||
|
||||
import type { OfstedInspection, SchoolAdmissions } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verdicts against an anchor (England average or state-school benchmark)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Verdict = 'above' | 'close' | 'below';
|
||||
|
||||
export function verdict(value: number, anchor: number, tolerance = 2): Verdict {
|
||||
if (value >= anchor + tolerance) return 'above';
|
||||
if (value <= anchor - tolerance) return 'below';
|
||||
return 'close';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ofsted — three regimes, one display model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OFSTED_LEGACY_GRADES: Record<number, string> = {
|
||||
1: 'Outstanding',
|
||||
2: 'Good',
|
||||
3: 'Requires improvement',
|
||||
4: 'Inadequate',
|
||||
};
|
||||
|
||||
/** rc_ key → the area label used across the reviewed mockups. */
|
||||
const RC_AREA_LABELS: Record<string, string> = {
|
||||
rc_inclusion: 'Inclusion',
|
||||
rc_curriculum_teaching: 'Curriculum & teaching',
|
||||
rc_achievement: 'Achievement',
|
||||
rc_attendance_behaviour: 'Attendance & behaviour',
|
||||
rc_personal_development: 'Personal development',
|
||||
rc_leadership_governance: 'Leadership & governance',
|
||||
rc_early_years: 'Early years',
|
||||
rc_sixth_form: 'Sixth form',
|
||||
};
|
||||
|
||||
export function rcAreaLabel(key: string): string {
|
||||
return RC_AREA_LABELS[key] ?? key;
|
||||
}
|
||||
|
||||
export interface ReportCardSummary {
|
||||
/** Graded areas only, grouped by label, best grade first. */
|
||||
counts: Array<{ label: string; count: number }>;
|
||||
/** Areas rated Needs attention / Urgent improvement — always named. */
|
||||
problems: Array<{ areaLabel: string; label: string }>;
|
||||
safeguarding: 'met' | 'not_met' | null;
|
||||
/** True when every graded area is Expected standard or better and
|
||||
* safeguarding is not "not met". */
|
||||
allClear: boolean;
|
||||
}
|
||||
|
||||
const PROBLEM_CODES = new Set([4, 5]);
|
||||
|
||||
export function summariseReportCard(ofsted: OfstedInspection): ReportCardSummary {
|
||||
const entries = Object.entries(ofsted.report_card ?? {});
|
||||
const byCode = new Map<number, { label: string; count: number }>();
|
||||
const problems: ReportCardSummary['problems'] = [];
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
if (PROBLEM_CODES.has(entry.code)) {
|
||||
problems.push({ areaLabel: rcAreaLabel(key), label: entry.label });
|
||||
} else {
|
||||
const existing = byCode.get(entry.code);
|
||||
if (existing) existing.count += 1;
|
||||
else byCode.set(entry.code, { label: entry.label, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const counts = [...byCode.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, v]) => v);
|
||||
|
||||
const safeguarding =
|
||||
ofsted.rc_safeguarding_met === true
|
||||
? 'met'
|
||||
: ofsted.rc_safeguarding_met === false
|
||||
? 'not_met'
|
||||
: null;
|
||||
|
||||
return {
|
||||
counts,
|
||||
problems,
|
||||
safeguarding,
|
||||
allClear: entries.length > 0 && problems.length === 0 && safeguarding !== 'not_met',
|
||||
};
|
||||
}
|
||||
|
||||
export type OfstedDisplay =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false }
|
||||
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
|
||||
| { kind: 'transitional' }
|
||||
| { kind: 'report_card'; summary: ReportCardSummary };
|
||||
|
||||
export function ofstedDisplay(
|
||||
ofsted: OfstedInspection | null | undefined,
|
||||
): OfstedDisplay {
|
||||
if (!ofsted) return { kind: 'none' };
|
||||
|
||||
// A report card is the newest inspection format; when present it wins —
|
||||
// never derive or prefer an overall grade alongside it.
|
||||
if (ofsted.report_card && Object.keys(ofsted.report_card).length > 0) {
|
||||
return { kind: 'report_card', summary: summariseReportCard(ofsted) };
|
||||
}
|
||||
|
||||
const grade = ofsted.overall_effectiveness;
|
||||
const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined;
|
||||
if (grade == null || gradeLabel === undefined) {
|
||||
if (ofsted.inspection_date) {
|
||||
return { kind: 'transitional' };
|
||||
}
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
if (ofsted.grade_source === 'ungraded_carried_forward') {
|
||||
return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true };
|
||||
}
|
||||
return { kind: 'graded', grade, gradeLabel, carriedForward: false };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admissions — one consistent chip metric (first-preference success)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AdmissionsSummary {
|
||||
firstPrefPct: number | null;
|
||||
chip: { tone: 'good' | 'warn' | 'neutral'; text: string } | null;
|
||||
/** e.g. "Named on 457 forms · 180 places" — total preferences at any rank,
|
||||
* deliberately not phrased as head-to-head applications. */
|
||||
interest: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the admissions round for the ACTIVE phase tab. An all-through school
|
||||
* can carry only a Year 7 (Secondary) round — rendering that beside pure
|
||||
* primaries' Reception rounds made 433-forms-for-173-places read as
|
||||
* Reception odds. Rows matching the target phase win (latest year first);
|
||||
* rows tagged with the OTHER phase are never substituted. Untagged rows
|
||||
* (legacy data, no school_phase) are used only when no row carries a phase.
|
||||
*/
|
||||
export function admissionsForPhase(
|
||||
data:
|
||||
| { admissions?: SchoolAdmissions | null; admissions_history?: SchoolAdmissions[] }
|
||||
| null
|
||||
| undefined,
|
||||
isSecondary: boolean,
|
||||
): SchoolAdmissions | null {
|
||||
if (!data) return null;
|
||||
const rows: SchoolAdmissions[] = [
|
||||
...(data.admissions_history ?? []),
|
||||
...(data.admissions ? [data.admissions] : []),
|
||||
];
|
||||
if (rows.length === 0) return null;
|
||||
const target = isSecondary ? 'secondary' : 'primary';
|
||||
const byYearDesc = (a: SchoolAdmissions, b: SchoolAdmissions) => (b.year ?? 0) - (a.year ?? 0);
|
||||
|
||||
const matching = rows
|
||||
.filter((r) => r.school_phase?.toLowerCase() === target)
|
||||
.sort(byYearDesc);
|
||||
if (matching.length > 0) return matching[0];
|
||||
|
||||
const tagged = rows.some((r) => r.school_phase != null);
|
||||
if (!tagged) return [...rows].sort(byYearDesc)[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function summariseAdmissions(
|
||||
a: SchoolAdmissions | null | undefined,
|
||||
): AdmissionsSummary {
|
||||
if (!a) return { firstPrefPct: null, chip: null, interest: null };
|
||||
|
||||
const pct =
|
||||
a.first_preference_offer_pct != null
|
||||
? Math.round(a.first_preference_offer_pct)
|
||||
: null;
|
||||
|
||||
let chip: AdmissionsSummary['chip'] = null;
|
||||
if (pct != null) {
|
||||
if (pct >= 100) {
|
||||
chip = { tone: 'good', text: 'All first choices offered' };
|
||||
} else if (pct < 50) {
|
||||
// Banded, not one blanket chip: "Over 1 in 4" on a school where more
|
||||
// than half missed out understated the worst cases by half.
|
||||
chip = { tone: 'warn', text: 'More than half of first choices missed out' };
|
||||
} else if (pct < 67) {
|
||||
chip = { tone: 'warn', text: 'About 1 in 3 first choices missed out' };
|
||||
} else if (pct < 75) {
|
||||
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
|
||||
} else {
|
||||
chip = { tone: pct >= 90 ? 'good' : 'neutral', text: `${pct}% of first choices offered` };
|
||||
}
|
||||
}
|
||||
|
||||
const interest =
|
||||
a.total_applications != null && a.places_offered != null
|
||||
? `Named on ${a.total_applications.toLocaleString('en-GB')} forms · ${a.places_offered.toLocaleString('en-GB')} places`
|
||||
: null;
|
||||
|
||||
return { firstPrefPct: pct, chip, interest };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress bands — DfE confidence-interval methodology
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function progressBand(
|
||||
score: number | null,
|
||||
lower: number | null,
|
||||
upper: number | null,
|
||||
): 'above' | 'average' | 'below' | null {
|
||||
if (score == null || lower == null || upper == null) return null;
|
||||
if (lower > 0) return 'above';
|
||||
if (upper < 0) return 'below';
|
||||
return 'average';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dot-strip geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StripPoint {
|
||||
/** 0–100 percentage position along the track. */
|
||||
pos: number;
|
||||
labelAbove: boolean;
|
||||
value: number;
|
||||
schoolIndex: number;
|
||||
}
|
||||
|
||||
/** Labels within 4% of the domain of a lower neighbour flip above the strip
|
||||
* (the reviewed mockups' collision nudge). */
|
||||
export function stripPositions(
|
||||
values: Array<number | null>,
|
||||
min = 0,
|
||||
max = 100,
|
||||
): StripPoint[] {
|
||||
const span = max - min;
|
||||
const points = values
|
||||
.map((value, schoolIndex) => ({ value, schoolIndex }))
|
||||
.filter((p): p is { value: number; schoolIndex: number } => p.value != null)
|
||||
.map((p) => ({
|
||||
value: p.value,
|
||||
schoolIndex: p.schoolIndex,
|
||||
pos: Math.min(100, Math.max(0, ((p.value - min) / span) * 100)),
|
||||
labelAbove: false,
|
||||
}));
|
||||
|
||||
const nudge = span * 0.04;
|
||||
let lastBelow = -Infinity;
|
||||
for (const p of [...points].sort((a, b) => a.value - b.value)) {
|
||||
if (p.value - lastBelow < nudge) {
|
||||
p.labelAbove = true;
|
||||
} else {
|
||||
lastBelow = p.value;
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Latest non-null yearly value of `metricKey` per school, in `urns` order. */
|
||||
export function latestValues(
|
||||
data: Record<string, { yearly_data: Array<{ year: number }> }>,
|
||||
urns: number[],
|
||||
metricKey: string,
|
||||
): Array<number | null> {
|
||||
return urns.map((urn) => {
|
||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const v = (rows[i] as Record<string, unknown>)[metricKey];
|
||||
if (typeof v === 'number' && !Number.isNaN(v)) return v;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
@@ -79,12 +79,16 @@ export interface School {
|
||||
export interface OfstedInspection {
|
||||
framework: 'OEIF' | 'ReportCard' | null;
|
||||
inspection_date: string | null;
|
||||
/** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
|
||||
rc_inspection_date?: string | null;
|
||||
inspection_type: string | null;
|
||||
// OEIF fields (old framework, pre-Nov 2025)
|
||||
overall_effectiveness: 1 | 2 | 3 | 4 | null;
|
||||
quality_of_education: number | null;
|
||||
behaviour_attitudes: number | null;
|
||||
personal_development: number | null;
|
||||
/** Sixth-form judgement where applicable; sentinel 9 = not applicable. */
|
||||
sixth_form_provision?: number | null;
|
||||
leadership_management: number | null;
|
||||
early_years_provision: number | null;
|
||||
previous_overall: number | null;
|
||||
@@ -99,6 +103,21 @@ export interface OfstedInspection {
|
||||
rc_leadership_governance: number | null;
|
||||
rc_early_years: number | null;
|
||||
rc_sixth_form: number | null;
|
||||
/** Where the effective overall grade came from: a graded (Section 5)
|
||||
* inspection, or carried forward from an ungraded (Section 8) outcome. */
|
||||
grade_source?: 'graded' | 'ungraded_carried_forward' | null;
|
||||
/** Renewed-framework (Nov 2025) area judgements, coded + labelled by the
|
||||
* backend from the live-sampled Ofsted vocabulary. Empty when the school
|
||||
* has no report-card inspection. Safeguarding is never included here. */
|
||||
report_card?: Record<string, ReportCardEntry>;
|
||||
/** The school's page on ofsted.gov.uk (never a deep report link). */
|
||||
ofsted_page_url?: string;
|
||||
report_url?: string | null;
|
||||
}
|
||||
|
||||
export interface ReportCardEntry {
|
||||
code: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SchoolCensus {
|
||||
@@ -129,6 +148,12 @@ export interface SchoolAdmissions {
|
||||
/** 1st-preference applications per place offered (>1 means oversubscribed). */
|
||||
oversubscription_ratio?: number | null;
|
||||
oversubscribed: boolean | null;
|
||||
total_offers?: number | null;
|
||||
second_preference_offers?: number | null;
|
||||
third_preference_offers?: number | null;
|
||||
/** Applications naming this school from families in another LA, and offers to them. */
|
||||
cross_la_applications?: number | null;
|
||||
cross_la_offers?: number | null;
|
||||
}
|
||||
|
||||
export interface SenDetail {
|
||||
@@ -172,6 +197,22 @@ export interface SchoolResult {
|
||||
school_id: number;
|
||||
year: number;
|
||||
|
||||
// Progress confidence intervals + writing working-towards (published for
|
||||
// years with progress measures, i.e. up to 2022/23)
|
||||
reading_progress_lower_ci?: number | null;
|
||||
reading_progress_upper_ci?: number | null;
|
||||
writing_progress_lower_ci?: number | null;
|
||||
writing_progress_upper_ci?: number | null;
|
||||
writing_working_towards_pct?: number | null;
|
||||
maths_progress_lower_ci?: number | null;
|
||||
maths_progress_upper_ci?: number | null;
|
||||
|
||||
// KS4 banding and disadvantage gaps
|
||||
/** DfE's own plain-English Progress 8 label, e.g. "Well above average". */
|
||||
progress_8_banding?: string | null;
|
||||
attainment_8_disadvantage_gap?: number | null;
|
||||
progress_8_disadvantage_gap?: number | null;
|
||||
|
||||
// Pupil numbers
|
||||
total_pupils: number | null;
|
||||
eligible_pupils: number | null;
|
||||
@@ -308,10 +349,48 @@ export interface SchoolDetailsResponse {
|
||||
export interface ComparisonData {
|
||||
school_info: School;
|
||||
yearly_data: SchoolResult[];
|
||||
// Supplementary blocks (additive; absent on an old backend)
|
||||
ofsted?: OfstedInspection | null;
|
||||
census?: SchoolCensus | null;
|
||||
admissions?: SchoolAdmissions | null;
|
||||
admissions_history?: SchoolAdmissions[];
|
||||
deprivation?: SchoolDeprivation | null;
|
||||
}
|
||||
|
||||
export interface BenchmarkBlock {
|
||||
eal_pct: number | null;
|
||||
sen_support_pct: number | null;
|
||||
disadvantaged_pct: number | null;
|
||||
fsm_pct?: number | null;
|
||||
median_pupils: number | null;
|
||||
/** Primary only — weighted by cohort size. */
|
||||
disadvantaged_rwm_expected_pct?: number | null;
|
||||
}
|
||||
|
||||
/** Computed from our dataset — NOT official DfE figures. UI copy must say
|
||||
* "state-school average (computed from our dataset)" (the `source` string). */
|
||||
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>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ComparisonResponse {
|
||||
comparison: Record<string, ComparisonData>;
|
||||
national_averages?: NationalAverages;
|
||||
benchmarks?: Benchmarks;
|
||||
}
|
||||
|
||||
export interface RankingItem {
|
||||
|
||||
@@ -59,6 +59,24 @@ export function truncate(text: string, maxLength: number): string {
|
||||
return text.slice(0, maxLength).trim() + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact school label for tight spaces (mobile compare rows, chip bars):
|
||||
* drop the trailing establishment-type words so "Barclay Primary School" →
|
||||
* "Barclay", "St Mary's Catholic Primary School" → "St Mary's". Falls back to
|
||||
* a length-capped truncation for names that don't carry a type suffix.
|
||||
*/
|
||||
export function shortName(name: string, maxLength = 32): string {
|
||||
let s = name
|
||||
.replace(
|
||||
/\s+(primary|junior|infant|nursery|community|foundation|catholic|academy|school|college)\b.*$/i,
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
if (!s) s = name;
|
||||
if (s.length > maxLength) s = s.slice(0, maxLength - 1).trim() + '…';
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a school's age range for display, e.g. "3-11" → "Ages 3–11".
|
||||
* Display-only — leaves the raw `age_range` field (used for sixth-form
|
||||
|
||||
@@ -38,6 +38,31 @@ default_args = {
|
||||
"retry_delay": timedelta(minutes=5),
|
||||
}
|
||||
|
||||
# The backend caches the marts DataFrame at startup; after any rebuild the
|
||||
# cache must be invalidated or the API serves stale (or empty) data until the
|
||||
# container restarts.
|
||||
INVALIDATE_CACHE_CMD = """
|
||||
set -e
|
||||
BACKEND_URL="${BACKEND_URL:-http://backend:80}"
|
||||
ADMIN_KEY="${ADMIN_API_KEY:-changeme}"
|
||||
|
||||
echo "Calling $BACKEND_URL/api/admin/reload ..."
|
||||
|
||||
response=$(curl -s -o /tmp/reload_response.json -w "%{http_code}" \\
|
||||
--connect-timeout 10 --max-time 120 \\
|
||||
-X POST "$BACKEND_URL/api/admin/reload" \\
|
||||
-H "X-API-Key: $ADMIN_KEY" \\
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
echo "HTTP status: $response"
|
||||
cat /tmp/reload_response.json
|
||||
|
||||
if [ "$response" != "200" ]; then
|
||||
echo "ERROR: backend cache reload failed (HTTP $response)"
|
||||
exit 1
|
||||
fi
|
||||
"""
|
||||
|
||||
|
||||
# ── Daily DAG (GIAS + downstream) ──────────────────────────────────────
|
||||
|
||||
@@ -91,7 +116,12 @@ print(f'Validation passed: {{count}} GIAS rows')
|
||||
bash_command=f"cd {PIPELINE_DIR} && python scripts/sync_typesense.py",
|
||||
)
|
||||
|
||||
extract_group >> validate_raw >> dbt_build >> sync_typesense
|
||||
invalidate_cache = BashOperator(
|
||||
task_id="invalidate_cache",
|
||||
bash_command=INVALIDATE_CACHE_CMD,
|
||||
)
|
||||
|
||||
extract_group >> validate_raw >> dbt_build >> sync_typesense >> invalidate_cache
|
||||
|
||||
|
||||
# ── Monthly DAG (Ofsted) ───────────────────────────────────────────────
|
||||
@@ -121,7 +151,12 @@ with DAG(
|
||||
bash_command=f"cd {PIPELINE_DIR} && python scripts/sync_typesense.py",
|
||||
)
|
||||
|
||||
extract_ofsted >> dbt_build_ofsted >> sync_typesense_ofsted
|
||||
invalidate_cache_ofsted = BashOperator(
|
||||
task_id="invalidate_cache",
|
||||
bash_command=INVALIDATE_CACHE_CMD,
|
||||
)
|
||||
|
||||
extract_ofsted >> dbt_build_ofsted >> sync_typesense_ofsted >> invalidate_cache_ofsted
|
||||
|
||||
|
||||
# ── Annual DAG (EES: KS2, KS4, Census, Admissions) ───────────────────
|
||||
@@ -145,7 +180,7 @@ with DAG(
|
||||
|
||||
dbt_build_ees = BashOperator(
|
||||
task_id="dbt_build",
|
||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+",
|
||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+",
|
||||
)
|
||||
|
||||
sync_typesense_ees = BashOperator(
|
||||
@@ -153,7 +188,12 @@ with DAG(
|
||||
bash_command=f"cd {PIPELINE_DIR} && python scripts/sync_typesense.py",
|
||||
)
|
||||
|
||||
extract_ees_group >> dbt_build_ees >> sync_typesense_ees
|
||||
invalidate_cache_ees = BashOperator(
|
||||
task_id="invalidate_cache",
|
||||
bash_command=INVALIDATE_CACHE_CMD,
|
||||
)
|
||||
|
||||
extract_ees_group >> dbt_build_ees >> sync_typesense_ees >> invalidate_cache_ees
|
||||
|
||||
|
||||
# ── Annual DAG (IDACI Deprivation) ────────────────────────────────────
|
||||
@@ -178,4 +218,9 @@ with DAG(
|
||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_idaci+ fact_deprivation+",
|
||||
)
|
||||
|
||||
extract_idaci >> dbt_build_idaci
|
||||
invalidate_cache_idaci = BashOperator(
|
||||
task_id="invalidate_cache",
|
||||
bash_command=INVALIDATE_CACHE_CMD,
|
||||
)
|
||||
|
||||
extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci
|
||||
|
||||
@@ -49,6 +49,9 @@ plugins:
|
||||
- name: mi_url
|
||||
kind: string
|
||||
description: Ofsted Management Information download URL
|
||||
- name: independent_mi_url
|
||||
kind: string
|
||||
description: Ofsted Independent Schools Management Information download URL
|
||||
|
||||
- name: tap-uk-fbit
|
||||
namespace: uk_fbit
|
||||
|
||||
@@ -564,6 +564,74 @@ class EESKs2NationalStream(Stream):
|
||||
yield record
|
||||
|
||||
|
||||
# ── KS4 National Headlines (national level only — one row per year) ──────────
|
||||
# Dataset: "National characteristics summary data" (Key stage 4 performance).
|
||||
# Official England state-funded headline measures, 2018/19 → latest.
|
||||
# Suppressed values ('z', 'x') → NULL downstream. Progress 8 is legitimately
|
||||
# absent in years with no KS2 baseline (e.g. 2024/25) — that is DfE policy,
|
||||
# not missing data.
|
||||
|
||||
_KS4_NATIONAL_CSV_URL = (
|
||||
"https://explore-education-statistics.service.gov.uk/data-catalogue/"
|
||||
"data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv"
|
||||
)
|
||||
|
||||
_KS4_NATIONAL_COL_MAP = {
|
||||
"attainment8_average": "attainment_8_score",
|
||||
"progress8_average": "progress_8_score",
|
||||
"engmath_94_percent": "english_maths_standard_pass_pct",
|
||||
"engmath_95_percent": "english_maths_strong_pass_pct",
|
||||
"ebacc_entering_percent": "ebacc_entry_pct",
|
||||
"ebacc_94_percent": "ebacc_standard_pass_pct",
|
||||
"ebacc_95_percent": "ebacc_strong_pass_pct",
|
||||
"ebacc_aps_average": "ebacc_avg_score",
|
||||
}
|
||||
|
||||
|
||||
class EESKs4NationalStream(Stream):
|
||||
"""National KS4 headline averages — one row per academic year.
|
||||
|
||||
Filters to geographic_level == 'National', establishment_type_group ==
|
||||
'All state-funded', breakdown_topic == 'Total', breakdown == 'Total'
|
||||
so only the England-wide all-pupils row per year is emitted.
|
||||
"""
|
||||
|
||||
name = "ees_ks4_national"
|
||||
primary_keys = ["time_period"]
|
||||
replication_key = None
|
||||
|
||||
schema = th.PropertiesList(
|
||||
th.Property("time_period", th.StringType, required=True),
|
||||
*[th.Property(out, th.StringType) for out in _KS4_NATIONAL_COL_MAP.values()],
|
||||
).to_dict()
|
||||
|
||||
def get_records(self, context):
|
||||
import pandas as pd
|
||||
|
||||
self.logger.info("Downloading KS4 national headlines: %s", _KS4_NATIONAL_CSV_URL)
|
||||
resp = requests.get(_KS4_NATIONAL_CSV_URL, timeout=60)
|
||||
resp.raise_for_status()
|
||||
|
||||
df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
|
||||
df.columns = [c.strip().lower() for c in df.columns]
|
||||
|
||||
for col, want in [
|
||||
("geographic_level", "national"),
|
||||
("establishment_type_group", "all state-funded"),
|
||||
("breakdown_topic", "total"),
|
||||
("breakdown", "total"),
|
||||
]:
|
||||
if col in df.columns:
|
||||
df = df[df[col].str.strip().str.lower() == want]
|
||||
|
||||
self.logger.info("Emitting %d national KS4 rows", len(df))
|
||||
for _, row in df.iterrows():
|
||||
record = {"time_period": row.get("time_period", "").strip()}
|
||||
for csv_col, field in _KS4_NATIONAL_COL_MAP.items():
|
||||
record[field] = row.get(csv_col, "").strip()
|
||||
yield record
|
||||
|
||||
|
||||
# ── Legacy KS2 (pre-COVID wide format from DfE performance tables) ────────────
|
||||
# The DfE "Compare School Performance" site published school-level KS2 CSVs
|
||||
# in a wide format (one row per school, ~300 columns). EES only has school-level
|
||||
@@ -903,6 +971,7 @@ class TapUKEES(Tap):
|
||||
LegacyKS2Stream(self),
|
||||
LegacyKS4Stream(self),
|
||||
EESKs2NationalStream(self),
|
||||
EESKs4NationalStream(self),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import io
|
||||
import re
|
||||
|
||||
@@ -14,20 +15,28 @@ GOV_UK_PAGE = (
|
||||
"monthly-management-information-ofsteds-school-inspections-outcomes"
|
||||
)
|
||||
|
||||
INDEPENDENT_GOV_UK_PAGE = (
|
||||
"https://www.gov.uk/government/statistical-data-sets/"
|
||||
"non-association-independent-schools-inspections-and-outcomes-management-information"
|
||||
)
|
||||
|
||||
# Column name → internal field, in priority order (first match wins).
|
||||
# Handles both current and older file formats.
|
||||
COLUMN_PRIORITY = {
|
||||
"urn": ["URN", "Urn", "urn"],
|
||||
"inspection_date": [
|
||||
"Inspection start date of latest OEIF graded inspection",
|
||||
"Inspection start date of latest OEIF standard inspection",
|
||||
"Inspection start date",
|
||||
"Inspection date",
|
||||
],
|
||||
"inspection_type": [
|
||||
"Inspection type of latest OEIF graded inspection",
|
||||
"Inspection type of latest OEIF standard inspection",
|
||||
"Inspection type",
|
||||
],
|
||||
"event_type_grouping": [
|
||||
"Event type grouping of latest OEIF standard inspection",
|
||||
"Event type grouping",
|
||||
"Inspection type grouping",
|
||||
],
|
||||
@@ -52,10 +61,12 @@ COLUMN_PRIORITY = {
|
||||
"Effectiveness of leadership and management",
|
||||
],
|
||||
"early_years_provision": [
|
||||
"Latest OEIF early years provision (where applicable)",
|
||||
"Latest OEIF early years provision",
|
||||
"Early years provision (where applicable)",
|
||||
],
|
||||
"sixth_form_provision": [
|
||||
"Latest OEIF sixth form provision (where applicable)",
|
||||
"Latest OEIF sixth form provision",
|
||||
"Sixth form provision (where applicable)",
|
||||
],
|
||||
@@ -68,20 +79,67 @@ COLUMN_PRIORITY = {
|
||||
"ungraded_inspection_date": [
|
||||
"Date of latest ungraded inspection",
|
||||
],
|
||||
# Report Card fields (post-Nov 2025 framework).
|
||||
"rc_safeguarding_met": ["Safeguarding standards"],
|
||||
"rc_inclusion": ["Inclusion"],
|
||||
"rc_curriculum_teaching": ["Curriculum and teaching"],
|
||||
"rc_achievement": ["Achievement"],
|
||||
"rc_attendance_behaviour": ["Attendance and behaviour"],
|
||||
"rc_personal_development": ["Personal development and wellbeing"],
|
||||
"rc_leadership_governance": ["Leadership and governance"],
|
||||
"rc_early_years": ["Early years (where applicable)"],
|
||||
"rc_sixth_form": ["Post-16 provision (where applicable)"],
|
||||
# Date of the latest FULL inspection — in the renewed framework this is
|
||||
# the report-card inspection's own start date (col "Inspection start
|
||||
# date"), distinct from the legacy OEIF graded/ungraded dates above.
|
||||
"rc_inspection_date": ["Inspection start date"],
|
||||
"report_url": [
|
||||
"Web Link (opens in new window)",
|
||||
"Web link to Ofsted provider page",
|
||||
"Web link",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def discover_csv_url() -> str | None:
|
||||
"""Scrape GOV.UK page to find the latest MI CSV download link."""
|
||||
"""Scrape GOV.UK page to find the latest MI CSV download link.
|
||||
|
||||
The page lists a decade of monthly files, oldest first — take the
|
||||
newest 'latest inspections as at <date>' link by parsing its date,
|
||||
never matches[0] (that is a 2017 file).
|
||||
"""
|
||||
resp = requests.get(GOV_UK_PAGE, timeout=30)
|
||||
resp.raise_for_status()
|
||||
# Look for CSV attachment links
|
||||
matches = re.findall(
|
||||
csv_links = re.findall(
|
||||
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
|
||||
resp.text,
|
||||
)
|
||||
if matches:
|
||||
return matches[0]
|
||||
|
||||
months = {
|
||||
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
|
||||
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12,
|
||||
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'jun': 6,
|
||||
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
|
||||
}
|
||||
parsed_links = []
|
||||
for link in csv_links:
|
||||
normalized = link.lower().replace('-', '_')
|
||||
if 'latest_inspections_as_at' not in normalized:
|
||||
continue
|
||||
match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized)
|
||||
if match:
|
||||
day, month_str, year = match.groups()
|
||||
month = months.get(month_str)
|
||||
if month:
|
||||
try:
|
||||
parsed_links.append((datetime(int(year), month, int(day)), link))
|
||||
except ValueError:
|
||||
continue
|
||||
parsed_links.sort(reverse=True)
|
||||
if parsed_links:
|
||||
return parsed_links[0][1]
|
||||
if csv_links:
|
||||
return csv_links[-1]
|
||||
# Fall back to ODS
|
||||
matches = re.findall(
|
||||
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
|
||||
@@ -90,6 +148,51 @@ def discover_csv_url() -> str | None:
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def discover_independent_csv_url() -> str | None:
|
||||
"""Scrape GOV.UK page to find the latest independent schools MI CSV download link."""
|
||||
resp = requests.get(INDEPENDENT_GOV_UK_PAGE, timeout=30)
|
||||
resp.raise_for_status()
|
||||
# Look for CSV attachment links
|
||||
csv_links = re.findall(
|
||||
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
|
||||
resp.text,
|
||||
)
|
||||
if not csv_links:
|
||||
# Fall back to ODS
|
||||
csv_links = re.findall(
|
||||
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
|
||||
resp.text,
|
||||
)
|
||||
|
||||
months = {
|
||||
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
|
||||
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12
|
||||
}
|
||||
|
||||
parsed_links = []
|
||||
for link in csv_links:
|
||||
normalized_link = link.lower().replace('-', '_')
|
||||
if 'most_recent' not in normalized_link:
|
||||
continue
|
||||
|
||||
match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized_link)
|
||||
if match:
|
||||
day, month_str, year = match.groups()
|
||||
month = months.get(month_str)
|
||||
if month:
|
||||
try:
|
||||
dt = datetime(int(year), month, int(day))
|
||||
parsed_links.append((dt, link))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
parsed_links.sort(reverse=True)
|
||||
if parsed_links:
|
||||
return parsed_links[0][1]
|
||||
|
||||
return csv_links[0] if csv_links else None
|
||||
|
||||
|
||||
class OfstedInspectionsStream(Stream):
|
||||
"""Stream: Ofsted inspection records."""
|
||||
|
||||
@@ -111,6 +214,16 @@ class OfstedInspectionsStream(Stream):
|
||||
th.Property("sixth_form_provision", th.StringType),
|
||||
th.Property("ungraded_outcome", th.StringType),
|
||||
th.Property("ungraded_inspection_date", th.StringType),
|
||||
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),
|
||||
th.Property("rc_inspection_date", th.StringType),
|
||||
th.Property("report_url", th.StringType),
|
||||
).to_dict()
|
||||
|
||||
@@ -124,15 +237,8 @@ class OfstedInspectionsStream(Stream):
|
||||
break
|
||||
return mapping
|
||||
|
||||
def get_records(self, context):
|
||||
import pandas as pd
|
||||
|
||||
url = self.config.get("mi_url") or discover_csv_url()
|
||||
if not url:
|
||||
self.logger.error("Could not discover Ofsted MI download URL")
|
||||
return
|
||||
|
||||
self.logger.info("Downloading Ofsted MI: %s", url)
|
||||
def _fetch_and_parse_url(self, url: str, pd) -> list[dict]:
|
||||
"""Download file and parse records."""
|
||||
resp = requests.get(url, timeout=120)
|
||||
resp.raise_for_status()
|
||||
|
||||
@@ -148,8 +254,6 @@ class OfstedInspectionsStream(Stream):
|
||||
lines = text.split("\n")
|
||||
header_idx = 0
|
||||
for i, line in enumerate(lines[:20]):
|
||||
# Match lines where URN appears as a CSV field (start or after comma),
|
||||
# not as a substring of words like "turn" or "return".
|
||||
if re.search(r'(?:^|,)\s*URN\s*(?:,|$)', line):
|
||||
header_idx = i
|
||||
break
|
||||
@@ -167,16 +271,38 @@ class OfstedInspectionsStream(Stream):
|
||||
for _, row in df.iterrows():
|
||||
record = {}
|
||||
for field, col in col_map.items():
|
||||
record[field] = row.get(col, None)
|
||||
val = row.get(col, None)
|
||||
if pd.isna(val):
|
||||
val = None
|
||||
record[field] = val
|
||||
|
||||
# Cast URN
|
||||
try:
|
||||
record["urn"] = int(record["urn"])
|
||||
record["urn"] = int(record.get("urn"))
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
|
||||
yield record
|
||||
|
||||
def get_records(self, context):
|
||||
import pandas as pd
|
||||
|
||||
# 1. State-funded schools
|
||||
state_url = self.config.get("mi_url") or discover_csv_url()
|
||||
if state_url:
|
||||
self.logger.info("Downloading Ofsted state-funded MI: %s", state_url)
|
||||
yield from self._fetch_and_parse_url(state_url, pd)
|
||||
else:
|
||||
self.logger.error("Could not discover Ofsted state-funded MI download URL")
|
||||
|
||||
# 2. Independent schools
|
||||
ind_url = self.config.get("independent_mi_url") or discover_independent_csv_url()
|
||||
if ind_url:
|
||||
self.logger.info("Downloading Ofsted independent MI: %s", ind_url)
|
||||
yield from self._fetch_and_parse_url(ind_url, pd)
|
||||
else:
|
||||
self.logger.error("Could not discover Ofsted independent MI download URL")
|
||||
|
||||
|
||||
class TapUKOfsted(Tap):
|
||||
"""Singer tap for UK Ofsted Management Information."""
|
||||
@@ -185,6 +311,7 @@ class TapUKOfsted(Tap):
|
||||
|
||||
config_jsonschema = th.PropertiesList(
|
||||
th.Property("mi_url", th.StringType, description="Direct URL to Ofsted MI file"),
|
||||
th.Property("independent_mi_url", th.StringType, description="Direct URL to Ofsted Independent Schools MI file"),
|
||||
).to_dict()
|
||||
|
||||
def discover_streams(self):
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Diagnose the three data gaps blocking the compare-screen redesign.
|
||||
|
||||
Run from repo root (network access required, no DB needed):
|
||||
uv run --with singer-sdk --with pandas --with requests \
|
||||
python pipeline/scripts/diagnose_compare_gaps.py
|
||||
|
||||
(singer_sdk is a transitive import of tap_uk_ees.tap / tap_uk_ofsted.tap and
|
||||
is not part of the repo's default environment, hence the `uv run --with`.)
|
||||
"""
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
|
||||
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}")
|
||||
if "geographic_level" in df.columns:
|
||||
nat = df[df["geographic_level"].str.strip().str.lower() == "national"]
|
||||
else:
|
||||
print(" geographic_level column missing — cannot isolate national rows")
|
||||
return
|
||||
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:
|
||||
try:
|
||||
zf = download_release_zip(release["id"])
|
||||
except Exception as e:
|
||||
print(f" {release['time_period']}: DOWNLOAD FAILED: {e}")
|
||||
continue
|
||||
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")
|
||||
print(f" all CSVs in zip: {[n for n in zf.namelist() if n.endswith('.csv')]}")
|
||||
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}")
|
||||
if url is None or not url.lower().endswith(".csv"):
|
||||
print(f" URL is not a CSV (likely ODS) — stopping this section. url={url!r}")
|
||||
return
|
||||
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}")
|
||||
print(f" all columns ({len(df.columns)}):")
|
||||
for c in df.columns:
|
||||
print(f" - {c!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_national_gps_science()
|
||||
check_ks2_attainment_years_subjects()
|
||||
check_ofsted_report_card_columns()
|
||||
|
||||
|
||||
# FINDINGS 2026-07-12: run via
|
||||
# uv run --with singer-sdk --with pandas --with requests \
|
||||
# python pipeline/scripts/diagnose_compare_gaps.py
|
||||
#
|
||||
# (a) National catalogue CSV (GPS/science) — NOT a source-data problem.
|
||||
# pt_gps_exp, pt_scita_exp, avg_readscore, avg_matscore, avg_gpsscore are
|
||||
# all PRESENT in the catalogue CSV and hold real numeric values for the
|
||||
# latest national row (time_period 202425: pt_gps_exp='72.6' ->
|
||||
# gps_expected_pct; pt_scita_exp='81.6' -> science_expected_pct).
|
||||
# national time_periods present: 201516, 201617, 201718, 201819, 201920,
|
||||
# 202021, 202122, 202223, 202324, 202425 (COVID years 201920/202021 are
|
||||
# present as rows but suppressed with 'x' per the module docstring, not
|
||||
# absent). So _KS2_NATIONAL_COL_MAP is correct and the extractor's own
|
||||
# read of the source is fine end-to-end -- the NULLs in
|
||||
# marts.fact_ks2_national_averages are NOT caused by a missing/renamed
|
||||
# source column. The gap must be introduced downstream of the tap
|
||||
# (staging/mart SQL, a stale/incomplete load, or a dbt model not
|
||||
# selecting these two columns) -- Task 5/6 should look at the dbt
|
||||
# staging model for ees_ks2_national and the mart definition, not the
|
||||
# tap/column-map.
|
||||
#
|
||||
# (b) EES KS2 attainment (school-level, "key-stage-2-attainment" publication)
|
||||
# releases found (via get_all_releases): [None, '202425', '202324',
|
||||
# '202223', '202122']. The `None` entry is the *current/latest* release
|
||||
# (its slug doesn't parse to a 6-digit time_period by _slug_to_time_period,
|
||||
# but the CSV inside carries time_period='202425' -- same data as the
|
||||
# 202425-labelled release).
|
||||
#
|
||||
# Only two of the four releases contain a school-level attainment CSV
|
||||
# matching "ks2_school_attainment_data*.csv":
|
||||
# - release None (latest): HAS IT -> time_periods=['202425']
|
||||
# subjects=['Grammar, punctuation and spelling', 'Maths', 'Reading',
|
||||
# 'Reading, writing and maths', 'Science', 'Writing']
|
||||
# - release 202324: HAS IT -> time_periods=['202324']
|
||||
# subjects= same 6 labels as above
|
||||
# - release 202223: NO school attainment CSV in ZIP. This
|
||||
# release's ZIP instead contains only LA/regional/national/MAT-level
|
||||
# files (e.g. ks2_regional_and_local_authority_*, ks2_multi_academy
|
||||
# _trusts_*, ks2_national_*); no data/*school*attainment*.csv file
|
||||
# exists at all in this release's package. This CONFIRMS the
|
||||
# "subject-level 2022/23 is NULL in prod" symptom: the source
|
||||
# release literally does not publish a school-level attainment file
|
||||
# for 202223 under this filename pattern -- it's not a tap bug.
|
||||
# - release 202122: NO school attainment CSV in ZIP. Same
|
||||
# situation: ZIP has only LA/regional/national-level files (e.g.
|
||||
# ks2_regional_and_local_authority_2016_to_2022_revised.csv,
|
||||
# ks2_national_school_characteristics_2016_to_2022_revised.csv);
|
||||
# no school-level attainment CSV present. This CONFIRMS "school-level
|
||||
# 2021/22 is absent" -- again a genuine source-data absence, not an
|
||||
# extractor bug.
|
||||
# Implication for Tasks 5/6/7: 202122 and 202223 school-level attainment
|
||||
# cannot be backfilled from the "key-stage-2-attainment" EES publication
|
||||
# via this filename pattern -- those two years must either be sourced
|
||||
# from a different EES dataset/file (e.g. one of the *_school_location_
|
||||
# and_pupil_characteristics or *_school_type_and_pupil_characteristics
|
||||
# files present in those ZIPs, which may carry school-level rows under a
|
||||
# different filename), left NULL with an explicit "source unavailable"
|
||||
# note, or backfilled from the legacy DfE "Compare School Performance"
|
||||
# wide-format CSVs referenced elsewhere in tap.py. Subject labels to use
|
||||
# when a source *is* found for 202324/202425:
|
||||
# 'Grammar, punctuation and spelling', 'Maths', 'Reading',
|
||||
# 'Reading, writing and maths', 'Science', 'Writing'
|
||||
# (Reading, writing and maths spans reading+writing+maths combined --
|
||||
# this is the RWM row.)
|
||||
#
|
||||
# (c) Ofsted MI CSV (report-card columns) — confirmed PRESENT.
|
||||
# discover_csv_url() resolved to (as at run time, latest inspections
|
||||
# 31 May 2026):
|
||||
# https://assets.publishing.service.gov.uk/media/6a27c45be13080622db38815/
|
||||
# Management_information_-_state-funded_schools_-_latest_inspections_as_at_31_May_2026.csv
|
||||
# This is a real .csv (not .ods) so section (c) ran to completion.
|
||||
# Exact report-card column headers (7 grade columns + their paired date
|
||||
# columns, all present verbatim, case/spacing exactly as below):
|
||||
# 'Safeguarding standards' / 'Safeguarding standards - date of grade'
|
||||
# 'Inclusion' / 'Inclusion - date of grade'
|
||||
# 'Curriculum and teaching' / 'Curriculum and teaching - date of grade'
|
||||
# 'Achievement' / 'Achievement - date of grade'
|
||||
# 'Attendance and behaviour' / 'Attendance and behaviour - date of grade'
|
||||
# 'Personal development and wellbeing' / 'Personal development and wellbeing - date of grade'
|
||||
# 'Leadership and governance' / 'Leadership and governance - date of grade'
|
||||
# Plus a related pass/fail-style field:
|
||||
# 'Latest OEIF safeguarding is effective?' (note: double space in the
|
||||
# header, verbatim from source -- preserve exactly when mapping)
|
||||
# These are the new-style "report card" single-word-area grades
|
||||
# (introduced alongside the "Attendance and behaviour" split from
|
||||
# "Personal development"); they coexist in the same CSV with the legacy
|
||||
# 5-judgement OEIF columns ('Latest OEIF overall effectiveness',
|
||||
# 'Latest OEIF quality of education', 'Latest OEIF behaviour and
|
||||
# attitudes', 'Latest OEIF personal development', 'Latest OEIF
|
||||
# effectiveness of leadership and management'). Task 7 should map the 7
|
||||
# report-card columns above (grade + date pairs, 6 of them, plus the
|
||||
# safeguarding-effective flag) rather than inventing new column names.
|
||||
|
||||
# TASK 6 VERIFICATION 2026-07-12: 2021/22 legacy KS2 school-level archive
|
||||
#
|
||||
# RESULT: BLOCKED at the source-data level. School-level KS2 attainment for
|
||||
# academic year 2021/22 was never published anywhere publicly by DfE -- not
|
||||
# in EES (confirmed by Task 1's finding (b) above), not in the legacy
|
||||
# "Compare School Performance" download wizard, and not as a standalone
|
||||
# performance-tables archive/ODS on assets.publishing.service.gov.uk. This
|
||||
# is a deliberate DfE decision, not a gap in our extraction logic.
|
||||
#
|
||||
# Confirming quote (Key stage 2 attainment 2021/22 release notes, via
|
||||
# https://explore-education-statistics.service.gov.uk/find-statistics/
|
||||
# key-stage-2-attainment/2021-22):
|
||||
# "We will not publish key stage 2 data for academic year 2021/22 in
|
||||
# performance tables (also known as Compare School and College
|
||||
# Performance)." ... "The Department will, however, still produce the
|
||||
# normal suite of key stage 2 accountability measures at school and
|
||||
# multi-academy trust level and share these securely with primary
|
||||
# schools, academy trusts and local authorities to inform school
|
||||
# improvement discussions."
|
||||
# (i.e. school-level 202122 KS2 results exist internally at DfE but were
|
||||
# withheld from every public channel: performance tables/CSCP, EES, and by
|
||||
# extension the legacy DfE archives the current legacy_ks2_urls entries in
|
||||
# meltano.yml were sourced from.)
|
||||
#
|
||||
# What was tried:
|
||||
# 1. Direct download URL pattern from the task brief:
|
||||
# https://www.compare-school-performance.service.gov.uk/download-data?download=true®ions=0&filters=KS2&fileformat=csv&year=2021-2022&meta=false
|
||||
# -> HTTP 404, HTML error page (not a CSV/ZIP). Saved response inspected;
|
||||
# confirmed 404 via response headers (`content-type: text/html`).
|
||||
# 2. Walked the actual multi-step download wizard at
|
||||
# https://www.compare-school-performance.service.gov.uk/download-data
|
||||
# with a browser User-Agent and a cookie jar, replicating the GET-based
|
||||
# form steps: currentstep=year (downloadYear=2021-2022) -> currentstep=
|
||||
# region (regiontype=all&la=0) -> currentstep=datatypes. On the final
|
||||
# "datatypes" step, the checkbox list for 2021-2022 has NO "ks2" (or
|
||||
# "ks2mats") option at all -- only ks4/ks4prov/ks4underlying/ks5* /
|
||||
# pupil-destination/absence/census/mats checkboxes are present.
|
||||
# Control check: repeating the same wizard walk for downloadYear=
|
||||
# 2018-2019, 2022-2023 and 2023-2024 shows a "ks2" (and "ks2mats")
|
||||
# checkbox present in all three; downloadYear=2020-2021 (COVID-cancelled
|
||||
# KS2 SATs year) also has NO ks2 checkbox, matching the pattern for a
|
||||
# year where school-level KS2 genuinely isn't published. 2021-2022
|
||||
# behaves identically to the cancelled 2020-2021 year, not like the
|
||||
# normal 2018-2019/2022-2023/2023-2024 years.
|
||||
# 3. Web search for a standalone KS2 2022 performance-tables archive
|
||||
# (e.g. "england_ks2final" for 2022) on assets.publishing.service.gov.uk
|
||||
# found no such file; only unrelated 2022/2023-dated documents.
|
||||
#
|
||||
# No ZIP was ever obtained -- /tmp/dfe-2021-2022-ks2.zip contains the 404
|
||||
# HTML error page from attempt (1) above, not a real archive. It contains
|
||||
# no england_ks2final.csv (there is no ZIP to look inside).
|
||||
#
|
||||
# Column-map check (brief's Step 1): NOT RUN -- there is no 2021/22
|
||||
# england_ks2final.csv to check headers against. This is moot until/unless
|
||||
# a non-public source (e.g. a manual/internal DfE extract) becomes
|
||||
# available; _LEGACY_KS2_COLUMN_MAP itself is unchanged and untested here.
|
||||
#
|
||||
# Recommendation: mark 202122 school-level KS2 as a genuine, permanent
|
||||
# source-data gap (not a backfill candidate) unless the project can obtain
|
||||
# the internal DfE extract DfE says it shared "securely with primary
|
||||
# schools, academy trusts and local authorities" -- that is not a route
|
||||
# available to this pipeline. Task 6's meltano.yml change (Step 2) and the
|
||||
# filebrowser upload should NOT proceed for 202122; there is nothing to
|
||||
# upload.
|
||||
|
||||
# TASK 7 VALUE SAMPLE 2026-07-12: live value_counts() over the 7 report-card
|
||||
# columns (plus the related safeguarding-effective flag) in the same MI CSV
|
||||
# resolved by discover_csv_url() as at run time (31 May 2026 inspections
|
||||
# file). Blank cells read as the literal string 'NULL' (matches
|
||||
# keep_default_na=False in tap.py). Observed non-blank values, verbatim:
|
||||
#
|
||||
# 'Safeguarding standards': 'Met' (1319), 'Not met' (10)
|
||||
# 'Inclusion': 'Expected standard' (710),
|
||||
# 'Strong standard' (447), 'Needs attention' (130), 'Exceptional' (23),
|
||||
# 'Urgent improvement' (19)
|
||||
# 'Curriculum and teaching': 'Expected standard' (797),
|
||||
# 'Needs attention' (287), 'Strong standard' (206),
|
||||
# 'Urgent improvement' (28), 'Exceptional' (11)
|
||||
# 'Achievement': 'Expected standard' (701),
|
||||
# 'Needs attention' (364), 'Strong standard' (207),
|
||||
# 'Urgent improvement' (39), 'Exceptional' (18)
|
||||
# 'Attendance and behaviour': 'Expected standard' (699),
|
||||
# 'Strong standard' (405), 'Needs attention' (188),
|
||||
# 'Urgent improvement' (21), 'Exceptional' (16)
|
||||
# 'Personal development and wellbeing': 'Expected standard' (728),
|
||||
# 'Strong standard' (504), 'Needs attention' (66), 'Exceptional' (23),
|
||||
# 'Urgent improvement' (8)
|
||||
# 'Leadership and governance': 'Expected standard' (813),
|
||||
# 'Strong standard' (292), 'Needs attention' (172),
|
||||
# 'Urgent improvement' (34), 'Exceptional' (18)
|
||||
# 'Latest OEIF safeguarding is effective?' (note double space, not used by
|
||||
# Task 7 -- kept for completeness): 'Yes' (12970), 'No' (96)
|
||||
#
|
||||
# So the 6 graded report-card columns share exactly one 5-value vocabulary:
|
||||
# {'Exceptional', 'Strong standard', 'Expected standard', 'Needs attention',
|
||||
# 'Urgent improvement'} -- no 'Attention needed' variant was observed
|
||||
# anywhere, so parse_report_card_grade.sql does NOT need that speculative
|
||||
# branch from the task brief. 'Safeguarding standards' is a separate
|
||||
# two-value vocabulary {'Met', 'Not met'}.
|
||||
#
|
||||
# Collision check: 'Achievement' matches by EXACT list-membership
|
||||
# (`candidate in df_columns`, a Python list containment check against the
|
||||
# full column-name list, not a substring/regex match) against only
|
||||
# ['Achievement', 'Achievement - date of grade'] -- the date-paired column
|
||||
# has a different exact string and is never selected. Same check for
|
||||
# 'Safeguarding standards' found only itself, its own date-of-grade column,
|
||||
# and the unrelated 'Latest OEIF safeguarding is effective?' column (not
|
||||
# mapped to any rc_* field). No legacy OEIF column is accidentally consumed
|
||||
# by an rc_ mapping.
|
||||
@@ -94,13 +94,23 @@ def main() -> None:
|
||||
for code_col, name_col, dict_name, field_key in FIELDS:
|
||||
pairs = (
|
||||
df[[code_col, name_col]]
|
||||
.loc[lambda d: (d[code_col] != "") & (d[name_col] != "")]
|
||||
.loc[lambda d: d[code_col] != ""]
|
||||
.drop_duplicates()
|
||||
)
|
||||
mapping = sorted((int(c), n) for c, n in pairs.itertuples(index=False))
|
||||
dupes = len(mapping) - len({c for c, _ in mapping})
|
||||
if dupes:
|
||||
sys.exit(f"{code_col}: {dupes} codes map to multiple names — investigate before generating")
|
||||
by_code: dict[int, set] = {}
|
||||
for c, n in pairs.itertuples(index=False):
|
||||
by_code.setdefault(int(c), set()).add(n)
|
||||
mapping = []
|
||||
for code, names in sorted(by_code.items()):
|
||||
named = sorted(n for n in names if n != "")
|
||||
if len(named) > 1:
|
||||
sys.exit(f"{code_col}: code {code} maps to multiple names {named} — investigate before generating")
|
||||
# Codes that only ever appear with a blank (name) are GIAS
|
||||
# "not recorded" sentinels (e.g. ReligiousCharacter 99,
|
||||
# AdmissionsPolicy 9). Map them to "" so the API serves the same
|
||||
# empty string the old name pipeline did — the "Unknown (<code>)"
|
||||
# path is reserved for genuinely new codes.
|
||||
mapping.append((code, named[0] if named else ""))
|
||||
lines = [f"{dict_name}: dict[int, str] = {{"]
|
||||
for code, name in mapping:
|
||||
escaped = name.replace('"', '\\"')
|
||||
|
||||
@@ -78,6 +78,7 @@ OFFICIAL_SIXTH_FORM: dict[int, str] = {
|
||||
0: "Not applicable",
|
||||
1: "Has a sixth form",
|
||||
2: "Does not have a sixth form",
|
||||
9: "",
|
||||
}
|
||||
|
||||
RELIGIOUS_CHARACTER: dict[int, str] = {
|
||||
@@ -128,12 +129,14 @@ RELIGIOUS_CHARACTER: dict[int, str] = {
|
||||
47: "Reformed Baptist",
|
||||
48: "Roman Catholic/Anglican",
|
||||
49: "Sunni Deobandi",
|
||||
99: "",
|
||||
}
|
||||
|
||||
ADMISSIONS_POLICY: dict[int, str] = {
|
||||
0: "Not applicable",
|
||||
2: "Selective",
|
||||
4: "Non-selective",
|
||||
9: "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Macro: Parse Ofsted Report Card grade (post-Nov 2025 framework) from text
|
||||
-- into the 5-point scale. Real values confirmed via a live sample of the MI
|
||||
-- CSV (see pipeline/scripts/diagnose_compare_gaps.py's
|
||||
-- "TASK 7 VALUE SAMPLE 2026-07-12" note) -- unrecognised text (including the
|
||||
-- 'NULL' sentinel used by the source CSV for blanks) parses to NULL, never
|
||||
-- errors.
|
||||
|
||||
{% 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 'urgent improvement' then 5
|
||||
else null
|
||||
end
|
||||
{% endmacro %}
|
||||
@@ -15,8 +15,11 @@ current_ks2 as (
|
||||
year, total_pupils, eligible_pupils,
|
||||
rwm_expected_pct, rwm_high_pct,
|
||||
reading_expected_pct, reading_high_pct, reading_avg_score, reading_progress,
|
||||
reading_progress_lower_ci, reading_progress_upper_ci,
|
||||
writing_expected_pct, writing_high_pct, writing_progress,
|
||||
writing_progress_lower_ci, writing_progress_upper_ci, writing_working_towards_pct,
|
||||
maths_expected_pct, maths_high_pct, maths_avg_score, maths_progress,
|
||||
maths_progress_lower_ci, maths_progress_upper_ci,
|
||||
gps_expected_pct, gps_high_pct, gps_avg_score, science_expected_pct,
|
||||
reading_absence_pct, writing_absence_pct, maths_absence_pct, gps_absence_pct, science_absence_pct,
|
||||
rwm_expected_boys_pct, rwm_high_boys_pct, rwm_expected_girls_pct, rwm_high_girls_pct,
|
||||
@@ -33,8 +36,11 @@ predecessor_ks2 as (
|
||||
ks2.year, ks2.total_pupils, ks2.eligible_pupils,
|
||||
ks2.rwm_expected_pct, ks2.rwm_high_pct,
|
||||
ks2.reading_expected_pct, ks2.reading_high_pct, ks2.reading_avg_score, ks2.reading_progress,
|
||||
ks2.reading_progress_lower_ci, ks2.reading_progress_upper_ci,
|
||||
ks2.writing_expected_pct, ks2.writing_high_pct, ks2.writing_progress,
|
||||
ks2.writing_progress_lower_ci, ks2.writing_progress_upper_ci, ks2.writing_working_towards_pct,
|
||||
ks2.maths_expected_pct, ks2.maths_high_pct, ks2.maths_avg_score, ks2.maths_progress,
|
||||
ks2.maths_progress_lower_ci, ks2.maths_progress_upper_ci,
|
||||
ks2.gps_expected_pct, ks2.gps_high_pct, ks2.gps_avg_score, ks2.science_expected_pct,
|
||||
ks2.reading_absence_pct, ks2.writing_absence_pct, ks2.maths_absence_pct, ks2.gps_absence_pct, ks2.science_absence_pct,
|
||||
ks2.rwm_expected_boys_pct, ks2.rwm_high_boys_pct, ks2.rwm_expected_girls_pct, ks2.rwm_high_girls_pct,
|
||||
|
||||
@@ -18,7 +18,8 @@ current_ks4 as (
|
||||
english_maths_strong_pass_pct, english_maths_standard_pass_pct,
|
||||
ebacc_entry_pct, ebacc_strong_pass_pct, ebacc_standard_pass_pct, ebacc_avg_score,
|
||||
gcse_grade_91_pct,
|
||||
sen_pct, sen_support_pct, sen_ehcp_pct
|
||||
sen_pct, sen_support_pct, sen_ehcp_pct,
|
||||
progress_8_banding, attainment_8_disadvantage_gap, progress_8_disadvantage_gap
|
||||
from all_ks4
|
||||
),
|
||||
|
||||
@@ -34,7 +35,8 @@ predecessor_ks4 as (
|
||||
ks4.english_maths_strong_pass_pct, ks4.english_maths_standard_pass_pct,
|
||||
ks4.ebacc_entry_pct, ks4.ebacc_strong_pass_pct, ks4.ebacc_standard_pass_pct, ks4.ebacc_avg_score,
|
||||
ks4.gcse_grade_91_pct,
|
||||
ks4.sen_pct, ks4.sen_support_pct, ks4.sen_ehcp_pct
|
||||
ks4.sen_pct, ks4.sen_support_pct, ks4.sen_ehcp_pct,
|
||||
ks4.progress_8_banding, ks4.attainment_8_disadvantage_gap, ks4.progress_8_disadvantage_gap
|
||||
from all_ks4 ks4
|
||||
inner join {{ ref('int_school_lineage') }} lin
|
||||
on ks4.urn = lin.predecessor_urn
|
||||
|
||||
@@ -34,6 +34,7 @@ select
|
||||
rc_leadership_governance,
|
||||
rc_early_years,
|
||||
rc_sixth_form,
|
||||
rc_inspection_date,
|
||||
report_url
|
||||
from ranked
|
||||
where rn = 1
|
||||
|
||||
@@ -42,12 +42,12 @@ models:
|
||||
tests:
|
||||
- accepted_values:
|
||||
severity: warn
|
||||
values: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49]
|
||||
values: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 99]
|
||||
- name: admissions_policy_code
|
||||
tests:
|
||||
- accepted_values:
|
||||
severity: warn
|
||||
values: [0, 2, 4]
|
||||
values: [0, 2, 4, 9]
|
||||
|
||||
- name: dim_location
|
||||
description: School location dimension with PostGIS geometry
|
||||
@@ -86,6 +86,13 @@ models:
|
||||
tests: [not_null]
|
||||
- name: year
|
||||
tests: [not_null]
|
||||
- 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
|
||||
tests:
|
||||
- unique:
|
||||
column_name: "urn || '-' || year"
|
||||
@@ -97,6 +104,15 @@ models:
|
||||
tests: [not_null]
|
||||
- name: year
|
||||
tests: [not_null]
|
||||
- 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"
|
||||
severity: warn
|
||||
- name: attainment_8_disadvantage_gap
|
||||
- name: progress_8_disadvantage_gap
|
||||
tests:
|
||||
- unique:
|
||||
column_name: "urn || '-' || year"
|
||||
@@ -117,6 +133,16 @@ models:
|
||||
- name: year
|
||||
tests: [not_null]
|
||||
|
||||
- name: fact_census_benchmarks
|
||||
description: >
|
||||
State-school context benchmarks from the pupil census — one row per
|
||||
phase (primary/secondary), latest census year. fsm_pct/eal_pct are
|
||||
pupil-weighted means; consumers label them "state-school average
|
||||
(computed from our dataset)", never "England average".
|
||||
columns:
|
||||
- name: phase
|
||||
tests: [not_null, unique]
|
||||
|
||||
- name: fact_admissions
|
||||
description: School admissions — one row per URN per year
|
||||
columns:
|
||||
@@ -124,6 +150,11 @@ models:
|
||||
tests: [not_null]
|
||||
- name: year
|
||||
tests: [not_null]
|
||||
- name: second_preference_offers
|
||||
- name: third_preference_offers
|
||||
- name: cross_la_applications
|
||||
- name: cross_la_offers
|
||||
- name: total_offers
|
||||
|
||||
- name: fact_finance
|
||||
description: School financial data — one row per URN per year
|
||||
@@ -139,6 +170,12 @@ models:
|
||||
- name: year
|
||||
tests: [not_null, unique]
|
||||
|
||||
- name: fact_ks4_national_averages
|
||||
description: Computed national KS4 averages (means across state schools in our dataset — not official DfE figures) — one row per academic year
|
||||
columns:
|
||||
- name: year
|
||||
tests: [not_null, unique]
|
||||
|
||||
- name: fact_deprivation
|
||||
description: IDACI deprivation index — one row per URN
|
||||
columns:
|
||||
|
||||
@@ -5,9 +5,14 @@ select
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
-- Mart: state-school context benchmarks from the pupil census — one row per
|
||||
-- phase, latest census year. Computed at import time (never per request).
|
||||
-- fsm_pct / eal_pct are pupil-weighted means, i.e. "what % of pupils", not
|
||||
-- "the median school" — this matches how DfE quotes national FSM/EAL rates.
|
||||
-- Consumers must label these "state-school average (computed from our
|
||||
-- dataset)" (spec §8.6), never "England average".
|
||||
|
||||
with latest as (
|
||||
select max(year) as year from {{ ref('fact_pupil_characteristics') }}
|
||||
),
|
||||
|
||||
classified as (
|
||||
select
|
||||
case
|
||||
when p.phase_type_grouping ilike '%primary%' then 'primary'
|
||||
when p.phase_type_grouping ilike '%secondary%' then 'secondary'
|
||||
end as phase,
|
||||
p.total_pupils,
|
||||
p.fsm_pct,
|
||||
p.eal_pct,
|
||||
l.year
|
||||
from {{ ref('fact_pupil_characteristics') }} p
|
||||
join latest l on p.year = l.year
|
||||
where p.total_pupils is not null and p.total_pupils > 0
|
||||
)
|
||||
|
||||
select
|
||||
phase,
|
||||
max(year) as year,
|
||||
round((sum(fsm_pct * total_pupils) filter (where fsm_pct is not null)
|
||||
/ nullif(sum(total_pupils) filter (where fsm_pct is not null), 0))::numeric, 1) as fsm_pct,
|
||||
round((sum(eal_pct * total_pupils) filter (where eal_pct is not null)
|
||||
/ nullif(sum(total_pupils) filter (where eal_pct is not null), 0))::numeric, 1) as eal_pct,
|
||||
round(percentile_cont(0.5) within group (order by total_pupils))::integer as median_pupils
|
||||
from classified
|
||||
where phase is not null
|
||||
group by phase
|
||||
@@ -15,13 +15,20 @@ select
|
||||
reading_high_pct,
|
||||
reading_avg_score,
|
||||
reading_progress,
|
||||
reading_progress_lower_ci,
|
||||
reading_progress_upper_ci,
|
||||
writing_expected_pct,
|
||||
writing_high_pct,
|
||||
writing_progress,
|
||||
writing_progress_lower_ci,
|
||||
writing_progress_upper_ci,
|
||||
writing_working_towards_pct,
|
||||
maths_expected_pct,
|
||||
maths_high_pct,
|
||||
maths_avg_score,
|
||||
maths_progress,
|
||||
maths_progress_lower_ci,
|
||||
maths_progress_upper_ci,
|
||||
gps_expected_pct,
|
||||
gps_high_pct,
|
||||
gps_avg_score,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
-- Mart: OFFICIAL DfE KS4 national headline averages — one row per academic
|
||||
-- year (England, state-funded, all pupils), from the EES national dataset.
|
||||
-- Replaces the previous unweighted school-level means, which were 7–15
|
||||
-- points off every headline measure and produced an arithmetically
|
||||
-- impossible national Progress 8. gcse_grade_91_pct has no official
|
||||
-- national series and is NULL (schema kept for the API model).
|
||||
|
||||
select
|
||||
year,
|
||||
attainment_8_score,
|
||||
progress_8_score,
|
||||
english_maths_standard_pass_pct,
|
||||
english_maths_strong_pass_pct,
|
||||
ebacc_entry_pct,
|
||||
ebacc_standard_pass_pct,
|
||||
ebacc_strong_pass_pct,
|
||||
ebacc_avg_score,
|
||||
cast(null as double precision) as gcse_grade_91_pct
|
||||
from {{ ref('stg_ees_ks4_national') }}
|
||||
order by year
|
||||
@@ -16,6 +16,9 @@ select
|
||||
progress_8_score,
|
||||
progress_8_lower_ci,
|
||||
progress_8_upper_ci,
|
||||
progress_8_banding,
|
||||
attainment_8_disadvantage_gap,
|
||||
progress_8_disadvantage_gap,
|
||||
progress_8_english,
|
||||
progress_8_maths,
|
||||
progress_8_ebacc,
|
||||
|
||||
@@ -23,5 +23,6 @@ select
|
||||
rc_leadership_governance,
|
||||
rc_early_years,
|
||||
rc_sixth_form,
|
||||
rc_inspection_date,
|
||||
report_url
|
||||
from {{ ref('stg_ofsted_inspections') }}
|
||||
|
||||
@@ -25,13 +25,20 @@ select
|
||||
ks2.reading_high_pct,
|
||||
ks2.reading_avg_score,
|
||||
ks2.reading_progress,
|
||||
ks2.reading_progress_lower_ci,
|
||||
ks2.reading_progress_upper_ci,
|
||||
ks2.writing_expected_pct,
|
||||
ks2.writing_high_pct,
|
||||
ks2.writing_progress,
|
||||
ks2.writing_progress_lower_ci,
|
||||
ks2.writing_progress_upper_ci,
|
||||
ks2.writing_working_towards_pct,
|
||||
ks2.maths_expected_pct,
|
||||
ks2.maths_high_pct,
|
||||
ks2.maths_avg_score,
|
||||
ks2.maths_progress,
|
||||
ks2.maths_progress_lower_ci,
|
||||
ks2.maths_progress_upper_ci,
|
||||
ks2.gps_expected_pct,
|
||||
ks2.gps_high_pct,
|
||||
ks2.gps_avg_score,
|
||||
@@ -61,6 +68,9 @@ select
|
||||
ks4.progress_8_maths,
|
||||
ks4.progress_8_ebacc,
|
||||
ks4.progress_8_open,
|
||||
ks4.progress_8_banding,
|
||||
ks4.attainment_8_disadvantage_gap,
|
||||
ks4.progress_8_disadvantage_gap,
|
||||
ks4.english_maths_strong_pass_pct,
|
||||
ks4.english_maths_standard_pass_pct,
|
||||
ks4.ebacc_entry_pct,
|
||||
|
||||
@@ -51,6 +51,9 @@ sources:
|
||||
- name: ees_ks2_national
|
||||
description: KS2 national headline averages from DfE EES data catalogue — one row per academic year
|
||||
|
||||
- name: ees_ks4_national
|
||||
description: Official KS4 national headline averages from DfE EES data catalogue — one row per academic year
|
||||
|
||||
# Phonics: no school-level data on EES (only national/LA level)
|
||||
|
||||
- name: fbit_finance
|
||||
|
||||
@@ -32,6 +32,11 @@ renamed as (
|
||||
{{ safe_numeric('times_put_as_any_preferred_school') }}::integer as total_applications,
|
||||
{{ safe_numeric('times_put_as_1st_preference') }}::integer as first_preference_applications,
|
||||
|
||||
-- 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,
|
||||
|
||||
-- Proportions
|
||||
-- first_preference_offer_pct: of families who listed this school FIRST,
|
||||
-- the percentage that received an offer. 0–100 scale.
|
||||
|
||||
@@ -39,6 +39,12 @@ pivoted as (
|
||||
max(case when subject = 'Reading'
|
||||
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||
then {{ safe_numeric('progress_measure_score') }} end) as reading_progress,
|
||||
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,
|
||||
max(case when subject = 'Reading'
|
||||
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||
then {{ safe_numeric('absent_or_not_able_to_access_percent') }} end) as reading_absence_pct,
|
||||
@@ -53,6 +59,15 @@ pivoted as (
|
||||
max(case when subject = 'Writing'
|
||||
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||
then {{ safe_numeric('progress_measure_score') }} end) as writing_progress,
|
||||
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,
|
||||
max(case when subject = 'Writing'
|
||||
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||
then {{ safe_numeric('absent_or_not_able_to_access_percent') }} end) as writing_absence_pct,
|
||||
@@ -70,6 +85,12 @@ pivoted as (
|
||||
max(case when subject = 'Maths'
|
||||
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||
then {{ safe_numeric('progress_measure_score') }} end) as maths_progress,
|
||||
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,
|
||||
max(case when subject = 'Maths'
|
||||
and breakdown_topic = 'All pupils' and breakdown = 'Total'
|
||||
then {{ safe_numeric('absent_or_not_able_to_access_percent') }} end) as maths_absence_pct,
|
||||
@@ -143,13 +164,20 @@ select
|
||||
p.reading_high_pct,
|
||||
p.reading_avg_score,
|
||||
p.reading_progress,
|
||||
p.reading_progress_lower_ci,
|
||||
p.reading_progress_upper_ci,
|
||||
p.writing_expected_pct,
|
||||
p.writing_high_pct,
|
||||
p.writing_progress,
|
||||
p.writing_progress_lower_ci,
|
||||
p.writing_progress_upper_ci,
|
||||
p.writing_working_towards_pct,
|
||||
p.maths_expected_pct,
|
||||
p.maths_high_pct,
|
||||
p.maths_avg_score,
|
||||
p.maths_progress,
|
||||
p.maths_progress_lower_ci,
|
||||
p.maths_progress_upper_ci,
|
||||
p.gps_expected_pct,
|
||||
p.gps_high_pct,
|
||||
p.gps_avg_score,
|
||||
|
||||
@@ -31,4 +31,10 @@ select
|
||||
|
||||
from {{ source('raw', 'ees_ks2_national') }}
|
||||
where time_period ~ '^[0-9]+$'
|
||||
and cast(trim(time_period) as integer) >= 201617
|
||||
-- 2015/16 was the first year of the current expected-standard tests, so it's
|
||||
-- the correct floor (not 2016/17 -- that excluded a real, comparable national
|
||||
-- row). GPS/science/scaled-score columns are already mapped correctly end to
|
||||
-- end (tap.py's _KS2_NATIONAL_COL_MAP + this model select them fine); the
|
||||
-- prod NULLs for those fields are stale raw.ees_ks2_national data from before
|
||||
-- the map covered them, not a mapping bug -- no map change accompanies this fix.
|
||||
and cast(trim(time_period) as integer) >= 201516
|
||||
|
||||
@@ -62,7 +62,16 @@ info as (
|
||||
{{ safe_numeric('ks2_scaledscore_average') }} as prior_attainment_avg,
|
||||
{{ safe_numeric('sen_pupil_percent') }} as sen_pct,
|
||||
{{ safe_numeric('sen_with_ehcp_pupil_percent') }} as sen_ehcp_pct,
|
||||
{{ safe_numeric('sen_no_ehcp_pupil_percent') }} as sen_support_pct
|
||||
{{ safe_numeric('sen_no_ehcp_pupil_percent') }} as sen_support_pct,
|
||||
-- EES suppression sentinels (z/c/x/q/u) and blanks must not reach the
|
||||
-- mart as banding labels
|
||||
case
|
||||
when lower(trim(progress8_banding)) in ('', 'z', 'c', 'x', 'q', 'u', 'null')
|
||||
then null
|
||||
else trim(progress8_banding)
|
||||
end as progress_8_banding,
|
||||
{{ safe_numeric('attainment8_diffn') }} as attainment_8_disadvantage_gap,
|
||||
{{ safe_numeric('progress8_diffn') }} as progress_8_disadvantage_gap
|
||||
from {{ source('raw', 'ees_ks4_info') }}
|
||||
where school_urn is not null
|
||||
)
|
||||
@@ -102,7 +111,10 @@ select
|
||||
-- Context
|
||||
i.sen_pct,
|
||||
i.sen_ehcp_pct,
|
||||
i.sen_support_pct
|
||||
i.sen_support_pct,
|
||||
i.progress_8_banding,
|
||||
i.attainment_8_disadvantage_gap,
|
||||
i.progress_8_disadvantage_gap
|
||||
|
||||
from all_pupils p
|
||||
left join info i on p.urn = i.urn and p.year = i.year
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
-- Staging model: official DfE KS4 national headline averages — one row per
|
||||
-- academic year (England, all state-funded, all pupils). Source: EES data
|
||||
-- catalogue "National characteristics summary data". Suppressed values
|
||||
-- ('z', 'x') are coerced to NULL by safe_numeric — Progress 8 is 'z' in
|
||||
-- years with no KS2 baseline (e.g. 2024/25): legitimately unpublished.
|
||||
|
||||
select
|
||||
cast(trim(time_period) as integer) as year,
|
||||
{{ safe_numeric('attainment_8_score') }} as attainment_8_score,
|
||||
{{ safe_numeric('progress_8_score') }} as progress_8_score,
|
||||
{{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
|
||||
{{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
|
||||
{{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
|
||||
{{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
|
||||
{{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
|
||||
{{ safe_numeric('ebacc_avg_score') }} as ebacc_avg_score
|
||||
from {{ source('raw', 'ees_ks4_national') }}
|
||||
where time_period ~ '^[0-9]+$'
|
||||
@@ -17,13 +17,23 @@ select
|
||||
{{ safe_numeric('reading_high_pct') }} as reading_high_pct,
|
||||
{{ safe_numeric('reading_avg_score') }} as reading_avg_score,
|
||||
{{ safe_numeric('reading_progress') }} as reading_progress,
|
||||
-- Progress CIs / working-towards: not published in the legacy CSVs.
|
||||
-- Typed placeholders keep positional alignment with stg_ees_ks2 in
|
||||
-- int_ks2_with_lineage's UNION ALL.
|
||||
null::numeric as reading_progress_lower_ci,
|
||||
null::numeric as reading_progress_upper_ci,
|
||||
{{ safe_numeric('writing_expected_pct') }} as writing_expected_pct,
|
||||
{{ safe_numeric('writing_high_pct') }} as writing_high_pct,
|
||||
{{ safe_numeric('writing_progress') }} as writing_progress,
|
||||
null::numeric as writing_progress_lower_ci,
|
||||
null::numeric as writing_progress_upper_ci,
|
||||
null::numeric as writing_working_towards_pct,
|
||||
{{ safe_numeric('maths_expected_pct') }} as maths_expected_pct,
|
||||
{{ safe_numeric('maths_high_pct') }} as maths_high_pct,
|
||||
{{ safe_numeric('maths_avg_score') }} as maths_avg_score,
|
||||
{{ safe_numeric('maths_progress') }} as maths_progress,
|
||||
null::numeric as maths_progress_lower_ci,
|
||||
null::numeric as maths_progress_upper_ci,
|
||||
{{ safe_numeric('gps_expected_pct') }} as gps_expected_pct,
|
||||
{{ safe_numeric('gps_high_pct') }} as gps_high_pct,
|
||||
{{ safe_numeric('gps_avg_score') }} as gps_avg_score,
|
||||
|
||||
@@ -41,8 +41,13 @@ select
|
||||
|
||||
-- SEN
|
||||
null::numeric as sen_pct,
|
||||
{{ safe_numeric('sen_ehcp_pct') }} as sen_ehcp_pct,
|
||||
{{ safe_numeric('sen_support_pct') }} as sen_support_pct,
|
||||
{{ safe_numeric('sen_ehcp_pct') }} as sen_ehcp_pct
|
||||
|
||||
-- Progress 8 banding & disadvantage gaps (not published in legacy format)
|
||||
null::text as progress_8_banding,
|
||||
null::numeric as attainment_8_disadvantage_gap,
|
||||
null::numeric as progress_8_disadvantage_gap
|
||||
|
||||
from {{ source('raw', 'legacy_ks4') }}
|
||||
where urn is not null
|
||||
|
||||
@@ -33,19 +33,29 @@ renamed as (
|
||||
nullif(trim(ungraded_outcome), 'NULL') as ungraded_outcome,
|
||||
{{ parse_ungraded_outcome('ungraded_outcome') }}::integer as ungraded_grade,
|
||||
|
||||
-- Report Card fields (post-Nov 2025 framework)
|
||||
-- TODO: add rc_* columns to tap-uk-ofsted schema once CSV column names are confirmed
|
||||
null::text as rc_safeguarding_met,
|
||||
null::text as rc_inclusion,
|
||||
null::text as rc_curriculum_teaching,
|
||||
null::text as rc_achievement,
|
||||
null::text as rc_attendance_behaviour,
|
||||
null::text as rc_personal_development,
|
||||
null::text as rc_leadership_governance,
|
||||
null::text as rc_early_years,
|
||||
null::text as rc_sixth_form,
|
||||
-- Report Card fields (post-Nov 2025 framework), 5-point scale:
|
||||
-- 1 Exceptional · 2 Strong standard · 3 Expected standard
|
||||
-- · 4 Needs attention · 5 Urgent improvement
|
||||
case lower(trim(nullif(rc_safeguarding_met, 'NULL')))
|
||||
when 'met' then true
|
||||
when 'not met' then false
|
||||
end 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,
|
||||
|
||||
report_url
|
||||
-- Start date of the latest FULL inspection (the report-card
|
||||
-- inspection in the renewed framework). Guarded in the final select:
|
||||
-- only kept when the row actually carries report-card grades, because
|
||||
-- in legacy-format files this column is the legacy inspection date.
|
||||
to_date(nullif(trim(rc_inspection_date), 'NULL'), 'DD/MM/YYYY') as rc_inspection_date_raw,
|
||||
|
||||
nullif(trim(report_url), 'NULL') as report_url
|
||||
from source
|
||||
where urn is not null
|
||||
and (
|
||||
@@ -54,5 +64,17 @@ renamed as (
|
||||
)
|
||||
)
|
||||
|
||||
select * from renamed
|
||||
select
|
||||
*,
|
||||
case
|
||||
when rc_safeguarding_met is not null
|
||||
or rc_inclusion is not null
|
||||
or rc_curriculum_teaching is not null
|
||||
or rc_achievement is not null
|
||||
or rc_attendance_behaviour is not null
|
||||
or rc_personal_development is not null
|
||||
or rc_leadership_governance is not null
|
||||
then rc_inspection_date_raw
|
||||
end as rc_inspection_date
|
||||
from renamed
|
||||
where inspection_date is not null
|
||||
|
||||
@@ -53,6 +53,7 @@ phase_of_education,7,All-through
|
||||
official_sixth_form,0,Not applicable
|
||||
official_sixth_form,1,Has a sixth form
|
||||
official_sixth_form,2,Does not have a sixth form
|
||||
official_sixth_form,9,
|
||||
religious_character,0,Does not apply
|
||||
religious_character,2,Church of England
|
||||
religious_character,3,Roman Catholic
|
||||
@@ -100,6 +101,8 @@ religious_character,46,Protestant/Evangelical
|
||||
religious_character,47,Reformed Baptist
|
||||
religious_character,48,Roman Catholic/Anglican
|
||||
religious_character,49,Sunni Deobandi
|
||||
religious_character,99,
|
||||
admissions_policy,0,Not applicable
|
||||
admissions_policy,2,Selective
|
||||
admissions_policy,4,Non-selective
|
||||
admissions_policy,9,
|
||||
|
||||
|
Reference in New Issue
Block a user