Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2dc78aeb5 | ||
|
|
619e3a1189 | ||
|
|
52f8994401 | ||
|
|
9990f540f7 | ||
|
|
6dd9b04b50 | ||
|
|
d0e71e2cf0 | ||
|
|
6138e2b2ee | ||
|
|
2f85b9c647 | ||
|
|
2155256177 | ||
|
|
519584f34b | ||
|
|
2573cd2490 | ||
|
|
9f2260ce50 | ||
|
|
4a8e798c64 | ||
|
|
60cbc3f46d | ||
|
|
48ca042b08 | ||
|
|
80f057ea5a | ||
|
|
9447b80bc6 |
+78
-70
@@ -772,93 +772,101 @@ async def get_la_averages(request: Request):
|
|||||||
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
|
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
|
||||||
|
|
||||||
|
|
||||||
|
_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_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 _national_averages_payload(df: pd.DataFrame) -> dict:
|
def _national_averages_payload(df: pd.DataFrame) -> dict:
|
||||||
"""National-averages payload shared by /api/national-averages and
|
"""National-averages payload shared by /api/national-averages and
|
||||||
/api/compare. Official DfE KS2 figures come from the mart table;
|
/api/compare.
|
||||||
KS4 figures are computed from our dataset (no DfE dataset yet)."""
|
|
||||||
|
Both series are persisted marts computed at import time: official DfE
|
||||||
|
KS2 figures (fact_ks2_national_averages) and dataset-computed KS4
|
||||||
|
averages (fact_ks4_national_averages) — the API never aggregates the
|
||||||
|
performance dataframe per request. If the KS4 mart hasn't been built
|
||||||
|
yet (deploy lands before the next DAG run), fall back to computing the
|
||||||
|
latest year only — a single-year scan, never the historical loop.
|
||||||
|
"""
|
||||||
if df.empty:
|
if df.empty:
|
||||||
return {"primary": {}, "secondary": {}}
|
return {"primary": {}, "secondary": {}}
|
||||||
|
|
||||||
ks2_metrics = [
|
latest_year = int(df["year"].max())
|
||||||
"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 = [
|
|
||||||
"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):
|
from . import database
|
||||||
|
from .models import Ks2NationalAverage, Ks4NationalAverage
|
||||||
|
|
||||||
|
def _row_metrics(row, metric_list):
|
||||||
out = {}
|
out = {}
|
||||||
for col in metric_list:
|
for col in metric_list:
|
||||||
if col in sub_df.columns:
|
val = getattr(row, col, None)
|
||||||
val = sub_df[col].dropna()
|
if val is not None:
|
||||||
if len(val) > 0:
|
out[col] = val
|
||||||
out[col] = round(float(val.mean()), 2)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
latest_year = int(df["year"].max())
|
ks2_rows: list = []
|
||||||
df_latest = df[df["year"] == latest_year]
|
ks4_rows: list = []
|
||||||
|
|
||||||
# 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()]
|
|
||||||
|
|
||||||
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 . import database
|
|
||||||
from .models import Ks2NationalAverage
|
|
||||||
|
|
||||||
by_year = []
|
|
||||||
db = None
|
db = None
|
||||||
try:
|
try:
|
||||||
db = database.SessionLocal()
|
db = database.SessionLocal()
|
||||||
nat_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
|
try:
|
||||||
# Build a lookup of computed secondary averages per year as fallback
|
ks2_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
|
||||||
secondary_by_year = {}
|
except Exception:
|
||||||
for yr in sorted(df["year"].dropna().unique()):
|
db.rollback()
|
||||||
yr = int(yr)
|
try:
|
||||||
df_yr = df[df["year"] == yr]
|
ks4_rows = db.query(Ks4NationalAverage).order_by(Ks4NationalAverage.year).all()
|
||||||
secondary_by_year[yr] = _means(
|
except Exception:
|
||||||
df_yr[df_yr["attainment_8_score"].notna()], ks4_metrics
|
db.rollback()
|
||||||
)
|
except Exception:
|
||||||
# Merge: official KS2 figures + computed KS4 figures per year
|
pass
|
||||||
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)
|
|
||||||
if val is not None:
|
|
||||||
primary_yr[col] = val
|
|
||||||
by_year.append({
|
|
||||||
"year": yr,
|
|
||||||
"primary": primary_yr,
|
|
||||||
"secondary": secondary_by_year.get(yr, {}),
|
|
||||||
})
|
|
||||||
finally:
|
finally:
|
||||||
if db is not None:
|
if db is not None:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# Update latest_primary with official DfE figure for the latest year if available
|
primary_by_year = {r.year: _row_metrics(r, _KS2_NATIONAL_METRICS) for r in ks2_rows}
|
||||||
if by_year:
|
secondary_by_year = {r.year: _row_metrics(r, _KS4_NATIONAL_METRICS) for r in ks4_rows}
|
||||||
latest_official = next((e["primary"] for e in reversed(by_year) if e["primary"]), None)
|
|
||||||
if latest_official:
|
if not any(secondary_by_year.values()):
|
||||||
latest_primary = latest_official
|
# KS4 mart missing/empty: compute the latest year only.
|
||||||
|
df_latest = df[df["year"] == latest_year]
|
||||||
|
sec = (
|
||||||
|
df_latest[df_latest["attainment_8_score"].notna()]
|
||||||
|
if "attainment_8_score" in df_latest.columns
|
||||||
|
else df_latest.iloc[0:0]
|
||||||
|
)
|
||||||
|
vals = {}
|
||||||
|
for col in _KS4_NATIONAL_METRICS:
|
||||||
|
if col in sec.columns:
|
||||||
|
v = sec[col].dropna()
|
||||||
|
if len(v) > 0:
|
||||||
|
vals[col] = round(float(v.mean()), 2)
|
||||||
|
if vals:
|
||||||
|
secondary_by_year[latest_year] = vals
|
||||||
|
|
||||||
|
all_years = sorted(set(primary_by_year) | set(secondary_by_year))
|
||||||
|
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 {
|
return {
|
||||||
"year": latest_year,
|
"year": latest_year,
|
||||||
|
|||||||
@@ -231,6 +231,23 @@ class FactFinance(Base):
|
|||||||
premises_cost_pct = Column(Float)
|
premises_cost_pct = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class Ks4NationalAverage(Base):
|
||||||
|
"""Computed national KS4 averages (from our dataset) — one row per year."""
|
||||||
|
__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):
|
class Ks2NationalAverage(Base):
|
||||||
"""Official DfE KS2 national headline averages — one row per academic year."""
|
"""Official DfE KS2 national headline averages — one row per academic year."""
|
||||||
__tablename__ = "fact_ks2_national_averages"
|
__tablename__ = "fact_ks2_national_averages"
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""_national_averages_payload reads persisted marts (computed at import
|
||||||
|
time) — it must never loop the dataframe per year. The only dataframe work
|
||||||
|
allowed is the single-latest-year KS4 fallback for the window between a
|
||||||
|
deploy and the next DAG run."""
|
||||||
|
|
||||||
|
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_missing_ks4_mart_falls_back_to_latest_year_only(payload):
|
||||||
|
body = payload(_Ks4MissingSession)
|
||||||
|
# Fallback computes the latest year from the df: mean(50, 30) = 40.0
|
||||||
|
assert body["secondary"]["attainment_8_score"] == 40.0
|
||||||
|
# ...and only the latest year — no historical KS4 loop
|
||||||
|
ks4_years = [e["year"] for e in body["by_year"] if e["secondary"]]
|
||||||
|
assert ks4_years == [LATEST]
|
||||||
@@ -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,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>
|
||||||
@@ -138,7 +138,7 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
|
|||||||
await expect(openFs).toBeVisible();
|
await expect(openFs).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('comparing two schools shows both side by side', async ({ page }) => {
|
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
|
||||||
// Collect two school URNs from search results, then load the share URL
|
// Collect two school URNs from search results, then load the share URL
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
@@ -152,6 +152,36 @@ test('comparing two schools shows both side by side', async ({ page }) => {
|
|||||||
// Both schools' detail links should render in the comparison view
|
// 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[0]}"]`).first()).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible();
|
await expect(page.locator(`a[href*="${urns[1]}"]`).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();
|
||||||
|
|
||||||
|
// 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('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
|
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
|
||||||
@@ -170,9 +200,22 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
|
|||||||
expect(urns.length).toBeGreaterThanOrEqual(3);
|
expect(urns.length).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
|
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
|
||||||
|
|
||||||
|
// 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 trends chart still renders (inside the Explore trends section)…
|
||||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// The mobile chart legend renders one chip per school in the active phase.
|
// …with the mobile chart legend chips and tap-to-focus behaviour intact.
|
||||||
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
||||||
const chips = chipGroup.getByRole('button');
|
const chips = chipGroup.getByRole('button');
|
||||||
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
|
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,84 @@
|
|||||||
|
/**
|
||||||
|
* 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('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,259 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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('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('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('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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,8 +16,10 @@ interface ComparePageProps {
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Compare Schools',
|
title: 'Compare Schools',
|
||||||
description: 'Compare KS2 performance across multiple primary schools in England',
|
description:
|
||||||
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
|
'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
|
// 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';
|
const selectedMetric = metricParam || 'rwm_expected_pct';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch comparison data if URNs provided
|
// Fetch comparison + metrics in parallel — they are independent.
|
||||||
let comparisonData = null;
|
const [comparisonResponse, metricsResponse] = await Promise.all([
|
||||||
if (urns.length > 0) {
|
urns.length > 0
|
||||||
try {
|
? fetchComparison(urnsParam!).catch((error) => {
|
||||||
const response = await fetchComparison(urnsParam!);
|
console.error('Failed to fetch comparison:', error);
|
||||||
comparisonData = response.comparison;
|
return null;
|
||||||
} catch (error) {
|
})
|
||||||
console.error('Failed to fetch comparison:', error);
|
: Promise.resolve(null),
|
||||||
}
|
fetchMetrics(),
|
||||||
}
|
]);
|
||||||
|
|
||||||
// Fetch available metrics
|
|
||||||
const metricsResponse = await fetchMetrics();
|
|
||||||
|
|
||||||
// Metrics is already an array
|
|
||||||
const metricsArray = metricsResponse?.metrics || [];
|
const metricsArray = metricsResponse?.metrics || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComparisonView
|
<ComparisonView
|
||||||
initialData={comparisonData}
|
initialData={comparisonResponse?.comparison ?? null}
|
||||||
|
initialNationalAverages={comparisonResponse?.national_averages}
|
||||||
|
initialBenchmarks={comparisonResponse?.benchmarks}
|
||||||
initialUrns={urns}
|
initialUrns={urns}
|
||||||
metrics={metricsArray}
|
metrics={metricsArray}
|
||||||
selectedMetric={selectedMetric}
|
selectedMetric={selectedMetric}
|
||||||
|
|||||||
@@ -65,3 +65,9 @@
|
|||||||
min-width: 0;
|
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 { Line } from 'react-chartjs-2';
|
||||||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
|
import { buildCompareChart } from '@/lib/compareChartData';
|
||||||
import type { ComparisonData } from '@/lib/types';
|
import type { ComparisonData } from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
CHART_COLORS,
|
CHART_COLORS,
|
||||||
@@ -34,13 +35,16 @@ interface ComparisonChartProps {
|
|||||||
schools: Array<{ urn: number; school_name: string }>;
|
schools: Array<{ urn: number; school_name: string }>;
|
||||||
metric: string;
|
metric: string;
|
||||||
metricLabel: 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
||||||
// converging lines stay tellable apart without relying on hue alone.
|
// converging lines stay tellable apart without relying on hue alone.
|
||||||
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
||||||
|
|
||||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) {
|
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -54,34 +58,48 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
|
|||||||
return <div>No data available</div>;
|
return <div>No data available</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Union of years across all schools — coverage differs between them.
|
// Pure, tested series construction: union of years with cancelled /
|
||||||
const years = [
|
// unpublished years kept as real gaps, plus the England overlay.
|
||||||
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
|
const built = buildCompareChart(comparisonData, schools, metric, nationalByYear);
|
||||||
].sort((a, b) => a - b);
|
const { years } = built;
|
||||||
|
|
||||||
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
|
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||||||
const data = comparisonData[String(school.urn)];
|
const school = schools[series.schoolIndex];
|
||||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
|
||||||
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: school.school_name,
|
label: series.label,
|
||||||
data: years.map((year) => {
|
data: series.data,
|
||||||
const yearData = data?.yearly_data.find((d) => d.year === year);
|
|
||||||
if (!yearData) return null;
|
|
||||||
return yearData[metric as keyof typeof yearData] as number | null;
|
|
||||||
}),
|
|
||||||
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
|
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
|
||||||
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
|
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
|
||||||
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
|
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,
|
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
|
||||||
pointHoverRadius: isMobile ? 5 : 6,
|
pointHoverRadius: isMobile ? 5 : 6,
|
||||||
tension: 0.3,
|
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 = {
|
const chartData = {
|
||||||
labels: years.map(formatAcademicYear),
|
labels: years.map(formatAcademicYear),
|
||||||
datasets,
|
datasets,
|
||||||
@@ -222,6 +240,12 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
|
|||||||
<div className={styles.canvasBox}>
|
<div className={styles.canvasBox}>
|
||||||
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
||||||
</div>
|
</div>
|
||||||
|
{built.showUnpublished202122Note && (
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,15 @@
|
|||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary, #5c564d);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
max-width: 60ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Phase Tabs */
|
/* Phase Tabs */
|
||||||
.phaseTabs {
|
.phaseTabs {
|
||||||
@@ -72,408 +79,87 @@
|
|||||||
background: var(--accent-coral-darker, #9c3f26);
|
background: var(--accent-coral-darker, #9c3f26);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metric Selector */
|
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||||
.metricSelector {
|
narrow screens */
|
||||||
background: var(--bg-card, white);
|
.schoolBar {
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
position: sticky;
|
||||||
border-radius: 12px;
|
top: 0;
|
||||||
padding: 1.5rem;
|
z-index: 10;
|
||||||
margin-bottom: 2rem;
|
background: var(--bg-primary, #faf7f2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 0.75rem;
|
||||||
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 {
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
max-width: 100%;
|
padding: 0.75rem 0;
|
||||||
margin-top: 1rem;
|
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right-edge fade so phone users see the comparison table scrolls.
|
.schoolChip {
|
||||||
Otherwise the wider-than-viewport table silently clips. */
|
flex: 1 1 0;
|
||||||
@media (max-width: 640px) {
|
min-width: 180px;
|
||||||
.tableWrapper {
|
background: var(--bg-card, white);
|
||||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
border: 1px solid var(--border-light, #e5dfd5);
|
||||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
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 {
|
.chipDot {
|
||||||
width: 100%;
|
width: 11px;
|
||||||
border-collapse: separate;
|
height: 11px;
|
||||||
border-spacing: 0;
|
border-radius: 50%;
|
||||||
font-size: 0.9375rem;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable thead {
|
.chipText {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable th {
|
.chipName {
|
||||||
padding: 1rem;
|
display: block;
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.25;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
text-decoration: none;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
}
|
||||||
|
|
||||||
|
.chipName:hover {
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipMeta {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-transform: uppercase;
|
overflow: hidden;
|
||||||
font-size: 0.75rem;
|
text-overflow: ellipsis;
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable td {
|
.chipRemove {
|
||||||
padding: 1rem;
|
margin-left: auto;
|
||||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
border: none;
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
text-align: left;
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comparisonTable thead th:first-child {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comparisonTable tbody tr:hover td:first-child {
|
|
||||||
background: var(--bg-secondary, #f3ede4);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable tbody tr:last-child td {
|
.footnote {
|
||||||
border-bottom: none;
|
font-size: 0.78rem;
|
||||||
}
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-top: 2.5rem;
|
||||||
.comparisonTable tbody tr:hover {
|
border-top: 1px solid var(--border-light, #e5dfd5);
|
||||||
background: var(--bg-secondary, #f3ede4);
|
padding-top: 1rem;
|
||||||
}
|
max-width: 75ch;
|
||||||
|
|
||||||
.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;
|
|
||||||
font-weight: 600;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricDescription {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
max-width: 600px;
|
|
||||||
flex-basis: 100%;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Responsive Design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.headerContent {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* ComparisonView Component
|
* ComparisonView — the parent-first compare screen: a sticky school bar and
|
||||||
* Client-side comparison interface with phase tabs, charts, and tables
|
* 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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
|
|
||||||
const ComparisonChart = dynamic(
|
|
||||||
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
import { CompareAtAGlance } from './compare/CompareAtAGlance';
|
||||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
import { CompareOfsted } from './compare/CompareOfsted';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils';
|
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 } from '@/lib/utils';
|
||||||
import { fetchComparison } from '@/lib/api';
|
import { fetchComparison } from '@/lib/api';
|
||||||
import { track } from '@/lib/analytics';
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './ComparisonView.module.css';
|
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 {
|
interface ComparisonViewProps {
|
||||||
initialData: Record<string, ComparisonData> | null;
|
initialData: Record<string, ComparisonData> | null;
|
||||||
|
initialNationalAverages?: NationalAverages;
|
||||||
|
initialBenchmarks?: Benchmarks;
|
||||||
initialUrns: number[];
|
initialUrns: number[];
|
||||||
metrics: MetricDefinition[];
|
metrics: MetricDefinition[];
|
||||||
selectedMetric: string;
|
selectedMetric: string;
|
||||||
@@ -51,6 +44,8 @@ interface ComparisonViewProps {
|
|||||||
|
|
||||||
export function ComparisonView({
|
export function ComparisonView({
|
||||||
initialData,
|
initialData,
|
||||||
|
initialNationalAverages,
|
||||||
|
initialBenchmarks,
|
||||||
initialUrns,
|
initialUrns,
|
||||||
metrics,
|
metrics,
|
||||||
selectedMetric: initialMetric,
|
selectedMetric: initialMetric,
|
||||||
@@ -58,11 +53,15 @@ export function ComparisonView({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison();
|
const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
|
||||||
|
|
||||||
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [comparisonData, setComparisonData] = useState(initialData);
|
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 [shareConfirm, setShareConfirm] = useState(false);
|
||||||
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||||
// Tracks whether the user has explicitly clicked a phase tab.
|
// Tracks whether the user has explicitly clicked a phase tab.
|
||||||
@@ -77,24 +76,27 @@ export function ComparisonView({
|
|||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
if (initialUrns.length > 0 && initialData) {
|
if (initialUrns.length > 0 && initialData) {
|
||||||
const urlSchools = initialUrns
|
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));
|
.filter((info): info is NonNullable<typeof info> => Boolean(info));
|
||||||
const sameSet =
|
const sameSet =
|
||||||
urlSchools.length === selectedSchools.length &&
|
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) {
|
if (urlSchools.length > 0 && !sameSet) {
|
||||||
replaceSchools(urlSchools);
|
replaceSchools(urlSchools);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isInitialized]); // 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(() => {
|
useEffect(() => {
|
||||||
const urns = selectedSchools.map((s) => s.urn).join(',');
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
if (urns) {
|
if (urnKey) {
|
||||||
params.set('urns', urns);
|
params.set('urns', urnKey);
|
||||||
} else {
|
} else {
|
||||||
params.delete('urns');
|
params.delete('urns');
|
||||||
}
|
}
|
||||||
@@ -103,23 +105,41 @@ export function ComparisonView({
|
|||||||
|
|
||||||
const newUrl = `${pathname}?${params.toString()}`;
|
const newUrl = `${pathname}?${params.toString()}`;
|
||||||
router.replace(newUrl, { scroll: false });
|
router.replace(newUrl, { scroll: false });
|
||||||
|
}, [urnKey, selectedMetric, pathname, searchParams, router]);
|
||||||
|
|
||||||
// Fetch comparison data
|
// Fetch only when the school set changes. The very first run is skipped
|
||||||
if (selectedSchools.length > 0) {
|
// when the SSR payload already covers the current set — no double-fetch
|
||||||
fetchComparison(urns, { cache: 'no-store' })
|
// of data the server just rendered.
|
||||||
.then((data) => {
|
const firstFetchRef = useRef(true);
|
||||||
setComparisonData(data.comparison);
|
useEffect(() => {
|
||||||
})
|
if (!urnKey) {
|
||||||
.catch((err) => {
|
|
||||||
// Keep whatever we already have (SSR data or a previous fetch) rather
|
|
||||||
// than blanking the chart — 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);
|
setComparisonData(null);
|
||||||
|
setNationalAverages(undefined);
|
||||||
|
setBenchmarks(undefined);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
|
||||||
|
if (firstFetchRef.current) {
|
||||||
|
firstFetchRef.current = false;
|
||||||
|
const ssrUrns = new Set(Object.keys(initialData ?? {}));
|
||||||
|
const covered = urnKey.split(',').every((urn) => ssrUrns.has(urn));
|
||||||
|
if (covered && ssrUrns.size > 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchComparison(urnKey, { cache: 'no-store' })
|
||||||
|
.then((data) => {
|
||||||
|
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 page — a transient refetch failure shouldn't
|
||||||
|
// destroy a working comparison the user is looking at.
|
||||||
|
console.error('Failed to fetch comparison:', err);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [urnKey]);
|
||||||
|
|
||||||
// Classify schools by phase using comparison data
|
// Classify schools by phase using comparison data
|
||||||
const classifySchool = (school: School): 'primary' | 'secondary' => {
|
const classifySchool = (school: School): 'primary' | 'secondary' => {
|
||||||
@@ -128,27 +148,22 @@ export function ComparisonView({
|
|||||||
if (info?.rwm_expected_pct != null) return 'primary';
|
if (info?.rwm_expected_pct != null) return 'primary';
|
||||||
// Fallback: check yearly data
|
// Fallback: check yearly data
|
||||||
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
|
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
|
||||||
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
|
if (yearlyData?.some((d) => d.attainment_8_score != null)) return 'secondary';
|
||||||
return 'primary';
|
return 'primary';
|
||||||
};
|
};
|
||||||
|
|
||||||
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
const primarySchools = selectedSchools.filter((s) => classifySchool(s) === 'primary');
|
||||||
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
const secondarySchools = selectedSchools.filter((s) => classifySchool(s) === 'secondary');
|
||||||
|
|
||||||
// Auto-select tab with more schools and sync the metric to match the detected phase.
|
// Auto-select tab with more schools and sync the metric to match the 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.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!comparisonData || selectedSchools.length === 0) return;
|
if (!comparisonData || selectedSchools.length === 0) return;
|
||||||
if (phaseLockedByUser.current) return;
|
if (phaseLockedByUser.current) return;
|
||||||
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
||||||
setComparePhase(newPhase);
|
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 phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
|
||||||
const metricFitsPhase = metrics.some(
|
const metricFitsPhase = metrics.some(
|
||||||
(m) => m.key === selectedMetric && phaseCategories.includes(m.category)
|
(m) => m.key === selectedMetric && phaseCategories.includes(m.category),
|
||||||
);
|
);
|
||||||
if (!metricFitsPhase) {
|
if (!metricFitsPhase) {
|
||||||
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||||
@@ -158,29 +173,24 @@ export function ComparisonView({
|
|||||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||||
phaseLockedByUser.current = true;
|
phaseLockedByUser.current = true;
|
||||||
setComparePhase(phase);
|
setComparePhase(phase);
|
||||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||||
setSelectedMetric(defaultMetric);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// compare_viewed: fire once after the page has its first selection.
|
// 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);
|
const compareViewedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (compareViewedRef.current) return;
|
if (compareViewedRef.current) return;
|
||||||
if (selectedSchools.length === 0) return;
|
if (selectedSchools.length === 0) return;
|
||||||
compareViewedRef.current = true;
|
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 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 });
|
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
|
||||||
}, [selectedSchools]);
|
}, [selectedSchools]);
|
||||||
|
|
||||||
const handleMetricChange = (metric: string) => {
|
|
||||||
track('compare_metric_changed', { metric, phase: comparePhase });
|
|
||||||
setSelectedMetric(metric);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveSchool = (urn: number) => {
|
const handleRemoveSchool = (urn: number) => {
|
||||||
removeSchool(urn);
|
removeSchool(urn);
|
||||||
track('compare_school_removed', { urn, from: 'compare' });
|
track('compare_school_removed', { urn, from: 'compare' });
|
||||||
@@ -191,21 +201,22 @@ export function ComparisonView({
|
|||||||
const count = selectedSchools.length;
|
const count = selectedSchools.length;
|
||||||
const shareData = {
|
const shareData = {
|
||||||
title: 'School comparison · SchoolCompare',
|
title: 'School comparison · SchoolCompare',
|
||||||
text: count > 0
|
text:
|
||||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
count > 0
|
||||||
: 'SchoolCompare',
|
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||||
|
: 'SchoolCompare',
|
||||||
url,
|
url,
|
||||||
};
|
};
|
||||||
// Prefer the native share sheet on platforms that support it (iOS / Android).
|
if (
|
||||||
// canShare is feature-detected because Safari iOS exposes share() but
|
typeof navigator !== 'undefined' &&
|
||||||
// some configurations refuse the payload.
|
navigator.share &&
|
||||||
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
(!navigator.canShare || navigator.canShare(shareData))
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await navigator.share(shareData);
|
await navigator.share(shareData);
|
||||||
track('compare_shared', { method: 'native', school_count: count });
|
track('compare_shared', { method: 'native', school_count: count });
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User cancelled — bail silently. Any other error falls through to clipboard.
|
|
||||||
if ((err as DOMException)?.name === 'AbortError') return;
|
if ((err as DOMException)?.name === 'AbortError') return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,27 +225,22 @@ export function ComparisonView({
|
|||||||
track('compare_shared', { method: 'clipboard', school_count: count });
|
track('compare_shared', { method: 'clipboard', school_count: count });
|
||||||
setShareConfirm(true);
|
setShareConfirm(true);
|
||||||
setTimeout(() => setShareConfirm(false), 2000);
|
setTimeout(() => setShareConfirm(false), 2000);
|
||||||
} catch { /* fallback: do nothing */ }
|
} catch {
|
||||||
|
/* fallback: do nothing */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPrimary = comparePhase === 'primary';
|
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;
|
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) {
|
if (selectedSchools.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>Compare Schools</h1>
|
<h1>Compare Schools</h1>
|
||||||
<p className={styles.subtitle}>
|
<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>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -252,39 +258,46 @@ export function ComparisonView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filtered comparison data for active phase
|
// Build filtered comparison data for the active phase
|
||||||
const activeComparisonData: Record<string, ComparisonData> = {};
|
const activeComparisonData: Record<string, ComparisonData> = {};
|
||||||
if (comparisonData) {
|
if (comparisonData) {
|
||||||
activeSchools.forEach(s => {
|
activeSchools.forEach((s) => {
|
||||||
if (comparisonData[s.urn]) {
|
if (comparisonData[s.urn]) {
|
||||||
activeComparisonData[s.urn] = comparisonData[s.urn];
|
activeComparisonData[s.urn] = comparisonData[s.urn];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const hasData = Object.keys(activeComparisonData).length > 0;
|
||||||
// Get years for table
|
|
||||||
const years =
|
|
||||||
Object.keys(activeComparisonData).length > 0
|
|
||||||
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Header */}
|
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.headerContent}>
|
<div className={styles.headerContent}>
|
||||||
<div>
|
<div>
|
||||||
<h1>Compare Schools</h1>
|
<h1>Compare Schools</h1>
|
||||||
<p className={styles.subtitle}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div className={styles.headerActions}>
|
||||||
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
|
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
|
||||||
+ Add School
|
+ Add School
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
|
<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'}
|
{shareConfirm ? 'Copied!' : 'Share'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,20 +305,22 @@ export function ComparisonView({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Phase Tabs */}
|
{/* Phase Tabs */}
|
||||||
<div className={styles.phaseTabs}>
|
{secondarySchools.length > 0 && primarySchools.length > 0 && (
|
||||||
<button
|
<div className={styles.phaseTabs}>
|
||||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
<button
|
||||||
onClick={() => handlePhaseChange('primary')}
|
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||||
>
|
onClick={() => handlePhaseChange('primary')}
|
||||||
Primary ({primarySchools.length})
|
>
|
||||||
</button>
|
Primary ({primarySchools.length})
|
||||||
<button
|
</button>
|
||||||
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
<button
|
||||||
onClick={() => handlePhaseChange('secondary')}
|
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||||
>
|
onClick={() => handlePhaseChange('secondary')}
|
||||||
Secondary ({secondarySchools.length})
|
>
|
||||||
</button>
|
Secondary ({secondarySchools.length})
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSchools.length === 0 ? (
|
{activeSchools.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -318,184 +333,82 @@ export function ComparisonView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Metric Selector */}
|
{/* Sticky school bar — column identity while scrolling */}
|
||||||
<section className={styles.metricSelector}>
|
<div className={styles.schoolBar} aria-label="Schools in this comparison">
|
||||||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
{activeSchools.map((school, index) => (
|
||||||
Select Metric:
|
<div
|
||||||
</label>
|
key={school.urn}
|
||||||
<select
|
className={styles.schoolChip}
|
||||||
id="metric-select"
|
style={{ borderTopColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||||
value={selectedMetric}
|
>
|
||||||
onChange={(e) => handleMetricChange(e.target.value)}
|
<span
|
||||||
className={styles.metricSelect}
|
className={styles.chipDot}
|
||||||
>
|
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||||
{optgroups.map(({ label, category }) => {
|
aria-hidden="true"
|
||||||
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}>
|
|
||||||
{activeSchools.map((school, index) => (
|
|
||||||
<div
|
|
||||||
key={school.urn}
|
|
||||||
className={styles.schoolCard}
|
|
||||||
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveSchool(school.urn)}
|
|
||||||
className={styles.removeButton}
|
|
||||||
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}
|
|
||||||
schools={activeSchools}
|
|
||||||
metric={selectedMetric}
|
|
||||||
metricLabel={metricLabel}
|
|
||||||
/>
|
/>
|
||||||
|
<span className={styles.chipText}>
|
||||||
|
<a className={styles.chipName} href={schoolUrl(school.urn, school.school_name)}>
|
||||||
|
{school.school_name}
|
||||||
|
</a>
|
||||||
|
<span className={styles.chipMeta}>
|
||||||
|
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveSchool(school.urn)}
|
||||||
|
className={styles.chipRemove}
|
||||||
|
aria-label={`Remove ${school.school_name}`}
|
||||||
|
title="Remove from comparison"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
))}
|
||||||
) : activeSchools.length > 0 ? (
|
</div>
|
||||||
<section className={styles.chartSection}>
|
|
||||||
<LoadingSkeleton type="list" />
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Comparison Table */}
|
{hasData && (
|
||||||
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
<>
|
||||||
<section className={styles.tableSection}>
|
<CompareAtAGlance
|
||||||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
schools={activeSchools}
|
||||||
<div className={styles.tableWrapper}>
|
data={activeComparisonData}
|
||||||
<table className={styles.comparisonTable}>
|
nationalAverages={nationalAverages}
|
||||||
<thead>
|
benchmarks={benchmarks}
|
||||||
<tr>
|
/>
|
||||||
<th>Year</th>
|
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
|
||||||
{activeSchools.map((school) => (
|
<CompareAcademics
|
||||||
<th key={school.urn}>{school.school_name}</th>
|
schools={activeSchools}
|
||||||
))}
|
data={activeComparisonData}
|
||||||
</tr>
|
nationalAverages={nationalAverages}
|
||||||
</thead>
|
benchmarks={benchmarks}
|
||||||
<tbody>
|
/>
|
||||||
{years.map((year) => (
|
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
|
||||||
<tr key={year}>
|
<CompareCommunity
|
||||||
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
schools={activeSchools}
|
||||||
{activeSchools.map((school) => {
|
data={activeComparisonData}
|
||||||
const schoolData = activeComparisonData[school.urn];
|
benchmarks={benchmarks}
|
||||||
if (!schoolData) return <td key={school.urn}>-</td>;
|
/>
|
||||||
|
<TrendsExplorer
|
||||||
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
metrics={metrics}
|
||||||
|
metric={selectedMetric}
|
||||||
|
onMetricChange={setSelectedMetric}
|
||||||
|
isPrimaryPhase={isPrimary}
|
||||||
|
nationalAverages={nationalAverages}
|
||||||
|
/>
|
||||||
|
|
||||||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
<p className={styles.footnote}>
|
||||||
if (!yearData) return <td key={school.urn}>-</td>;
|
Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection
|
||||||
|
outcomes, DfE school admissions data, school census. England averages for test
|
||||||
const value = yearData[selectedMetric as keyof typeof yearData];
|
results are official DfE figures; other benchmarks are state-school averages
|
||||||
|
computed from our dataset. Following DfE practice, figures based on 5 or fewer
|
||||||
if (value === null || value === undefined) {
|
pupils are suppressed and shown as "no data".
|
||||||
return <td key={school.urn}>-</td>;
|
</p>
|
||||||
}
|
</>
|
||||||
|
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* School Search Modal */}
|
|
||||||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||||
</div>
|
</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,292 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}: {
|
||||||
|
schools: School[];
|
||||||
|
data: Record<string, ComparisonData>;
|
||||||
|
nationalAverages?: NationalAverages;
|
||||||
|
benchmarks?: Benchmarks;
|
||||||
|
}) {
|
||||||
|
const urns = schools.map((school) => school.urn);
|
||||||
|
const schoolNames = schools.map((school) => school.school_name);
|
||||||
|
const isSecondary = 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;
|
||||||
|
});
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
<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;
|
||||||
|
|
||||||
|
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>{' '}
|
||||||
|
{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,124 @@
|
|||||||
|
/**
|
||||||
|
* 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 { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
|
import type { ComparisonData, School } from '@/lib/types';
|
||||||
|
import { CHART_COLORS } from '@/lib/utils';
|
||||||
|
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||||
|
|
||||||
|
export function CompareAdmissions({
|
||||||
|
schools,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
schools: School[];
|
||||||
|
data: Record<string, ComparisonData>;
|
||||||
|
}) {
|
||||||
|
const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null);
|
||||||
|
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 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}>
|
||||||
|
<RowLabel tip="How many application forms named the school at any preference rank — not the number of families competing head-to-head for a place.">
|
||||||
|
Interest in the school
|
||||||
|
</RowLabel>
|
||||||
|
{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}>No data</span>
|
||||||
|
)}
|
||||||
|
</Cell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel>First-choice families offered a place</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel>What this means</RowLabel>
|
||||||
|
{schools.map((school, i) => {
|
||||||
|
const a = rows[i];
|
||||||
|
const summary = summariseAdmissions(a);
|
||||||
|
let text: string | null = null;
|
||||||
|
if (summary.firstPrefPct != null) {
|
||||||
|
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 =
|
||||||
|
'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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SectionGrid>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
latestValues,
|
||||||
|
ofstedDisplay,
|
||||||
|
summariseAdmissions,
|
||||||
|
verdict,
|
||||||
|
type ReportCardSummary,
|
||||||
|
} from '@/lib/compareLogic';
|
||||||
|
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||||
|
import { Cell, Chip, RowLabel, 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,
|
||||||
|
}: {
|
||||||
|
schools: School[];
|
||||||
|
data: Record<string, ComparisonData>;
|
||||||
|
nationalAverages?: NationalAverages;
|
||||||
|
benchmarks?: Benchmarks;
|
||||||
|
}) {
|
||||||
|
const urns = schools.map((school) => school.urn);
|
||||||
|
const isSecondary = 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}>
|
||||||
|
<RowLabel>Latest Ofsted inspection</RowLabel>
|
||||||
|
{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 === 'none' && <span className={s.small}>No inspection in our dataset</span>}
|
||||||
|
</Cell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel
|
||||||
|
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).'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSecondary ? 'Attainment 8 score' : 'Children reaching the expected standard'}
|
||||||
|
</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel>Getting a place</RowLabel>
|
||||||
|
{schools.map((school, i) => {
|
||||||
|
const summary = summariseAdmissions(data[String(school.urn)]?.admissions);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel>Size</RowLabel>
|
||||||
|
{schools.map((school, i) => {
|
||||||
|
const census = data[String(school.urn)]?.census;
|
||||||
|
const pupils = census?.total_pupils ?? school.total_pupils ?? null;
|
||||||
|
let sizeNote: string | null = null;
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SectionGrid>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* 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, RowLabel, 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,
|
||||||
|
}: {
|
||||||
|
schools: School[];
|
||||||
|
data: Record<string, ComparisonData>;
|
||||||
|
benchmarks?: Benchmarks;
|
||||||
|
}) {
|
||||||
|
const isSecondary = 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) => {
|
||||||
|
if (value == null || bench?.disadvantaged_pct == null) return null;
|
||||||
|
const v = verdict(value, bench.disadvantaged_pct, 3);
|
||||||
|
return (
|
||||||
|
<Chip tone="neutral">
|
||||||
|
{v === 'above' && 'Above the state-school average'}
|
||||||
|
{v === 'close' && 'About the state-school average'}
|
||||||
|
{v === 'below' && 'Below the state-school average'}
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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."
|
||||||
|
>
|
||||||
|
<SectionGrid schools={schools}>
|
||||||
|
<RowLabel>Pupils on roll</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel>Girls / boys</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel 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.">
|
||||||
|
Free school meals
|
||||||
|
</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel tip="% of pupils whose first language is known or believed to be other than English. State-school average computed from our dataset.">
|
||||||
|
English as an additional language
|
||||||
|
</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel 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.">
|
||||||
|
Extra learning support (SEN)
|
||||||
|
</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel>Faith character</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel>Ages</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel>Run by</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SectionGrid>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* 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, RowLabel, 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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]> = [
|
||||||
|
['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],
|
||||||
|
];
|
||||||
|
const published = legacyAreas.filter(([, grade]) => grade != null);
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<RowLabel>Result</RowLabel>
|
||||||
|
{schools.map((school, i) => (
|
||||||
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
|
<ResultCell display={displays[i]} />
|
||||||
|
</Cell>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<RowLabel>Inspected</RowLabel>
|
||||||
|
{schools.map((school, i) => {
|
||||||
|
const ofsted = data[String(school.urn)]?.ofsted;
|
||||||
|
const age = yearsSince(ofsted?.inspection_date ?? null);
|
||||||
|
return (
|
||||||
|
<Cell key={school.urn} school={school} index={i}>
|
||||||
|
{formatInspectionDate(ofsted?.inspection_date ?? null)}{' '}
|
||||||
|
{age != null && age > 4 && <Chip tone="neutral">4+ years ago</Chip>}
|
||||||
|
</Cell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel 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.">
|
||||||
|
Judgement detail
|
||||||
|
</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RowLabel tip="Links to the school's page on ofsted.gov.uk, where all its inspection reports are listed.">
|
||||||
|
Ofsted page
|
||||||
|
</RowLabel>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SectionGrid>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartBox {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearCell {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* 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, plus the year-by-year table. Progress
|
||||||
|
* metrics carry CI-based bands for the years DfE published them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
import { progressBand } from '@/lib/compareLogic';
|
||||||
|
import type { ComparisonData, MetricDefinition, NationalAverages, School } from '@/lib/types';
|
||||||
|
import { formatAcademicYear, formatMetricValue, metricKind } from '@/lib/utils';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
|
import { Chip, Section, sectionStyles as s } 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);
|
||||||
|
|
||||||
|
const PROGRESS_CI: Record<string, [string, string]> = {
|
||||||
|
reading_progress: ['reading_progress_lower_ci', 'reading_progress_upper_ci'],
|
||||||
|
writing_progress: ['writing_progress_lower_ci', 'writing_progress_upper_ci'],
|
||||||
|
maths_progress: ['maths_progress_lower_ci', 'maths_progress_upper_ci'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BAND_LABEL = { above: 'Above average', average: 'Average', below: 'Below average' } as const;
|
||||||
|
|
||||||
|
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 years = [
|
||||||
|
...new Set(
|
||||||
|
schools.flatMap(
|
||||||
|
(school) => data[String(school.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const handleMetricChange = (next: string) => {
|
||||||
|
track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' });
|
||||||
|
onMetricChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ciKeys = PROGRESS_CI[metric];
|
||||||
|
|
||||||
|
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);
|
||||||
|
bands use DfE's confidence intervals, not the raw score alone.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.chartBox}>
|
||||||
|
<ComparisonChart
|
||||||
|
comparisonData={data}
|
||||||
|
schools={schools}
|
||||||
|
metric={metric}
|
||||||
|
metricLabel={metricLabel}
|
||||||
|
nationalByYear={nationalByYear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{years.length > 0 && (
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Year</th>
|
||||||
|
{schools.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>
|
||||||
|
{schools.map((school) => {
|
||||||
|
const row = data[String(school.urn)]?.yearly_data.find(
|
||||||
|
(d) => Math.trunc(d.year) === year,
|
||||||
|
) as (Record<string, unknown> & { year: number }) | undefined;
|
||||||
|
const value = row?.[metric];
|
||||||
|
if (typeof value !== 'number') return <td key={school.urn}>–</td>;
|
||||||
|
const band = ciKeys
|
||||||
|
? progressBand(
|
||||||
|
value,
|
||||||
|
(row?.[ciKeys[0]] as number | null) ?? null,
|
||||||
|
(row?.[ciKeys[1]] as number | null) ?? null,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<td key={school.urn}>
|
||||||
|
{formatMetricValue(value, metricKind(metric))}{' '}
|
||||||
|
{band && (
|
||||||
|
<Chip tone={band === 'above' ? 'good' : band === 'below' ? 'warn' : 'neutral'}>
|
||||||
|
{BAND_LABEL[band]}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowLabel {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell::before {
|
||||||
|
content: attr(data-school);
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sc, var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowLabel {
|
||||||
|
background: none;
|
||||||
|
border-radius: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0.85rem 0.5rem 0.85rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
padding: 0.85rem 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 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_TEXT_COLORS } 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Cell({
|
||||||
|
school,
|
||||||
|
index,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
school: School;
|
||||||
|
index: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.cell}
|
||||||
|
data-school={school.school_name}
|
||||||
|
style={{ '--sc': CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] } as CSSProperties}
|
||||||
|
>
|
||||||
|
{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;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 idx202122 = years.indexOf(202122);
|
||||||
|
const showUnpublished202122Note =
|
||||||
|
idx202122 >= 0 &&
|
||||||
|
englandDataset?.data[idx202122] != null &&
|
||||||
|
schoolDatasets.every((ds) => ds.data[idx202122] == null);
|
||||||
|
|
||||||
|
return { years, schoolDatasets, englandDataset, showUnpublished202122Note };
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* 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: '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) 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 < 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -99,6 +99,21 @@ export interface OfstedInspection {
|
|||||||
rc_leadership_governance: number | null;
|
rc_leadership_governance: number | null;
|
||||||
rc_early_years: number | null;
|
rc_early_years: number | null;
|
||||||
rc_sixth_form: 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 {
|
export interface SchoolCensus {
|
||||||
@@ -129,6 +144,12 @@ export interface SchoolAdmissions {
|
|||||||
/** 1st-preference applications per place offered (>1 means oversubscribed). */
|
/** 1st-preference applications per place offered (>1 means oversubscribed). */
|
||||||
oversubscription_ratio?: number | null;
|
oversubscription_ratio?: number | null;
|
||||||
oversubscribed: boolean | 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 {
|
export interface SenDetail {
|
||||||
@@ -172,6 +193,22 @@ export interface SchoolResult {
|
|||||||
school_id: number;
|
school_id: number;
|
||||||
year: 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
|
// Pupil numbers
|
||||||
total_pupils: number | null;
|
total_pupils: number | null;
|
||||||
eligible_pupils: number | null;
|
eligible_pupils: number | null;
|
||||||
@@ -308,10 +345,47 @@ export interface SchoolDetailsResponse {
|
|||||||
export interface ComparisonData {
|
export interface ComparisonData {
|
||||||
school_info: School;
|
school_info: School;
|
||||||
yearly_data: SchoolResult[];
|
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;
|
||||||
|
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 {
|
export interface ComparisonResponse {
|
||||||
comparison: Record<string, ComparisonData>;
|
comparison: Record<string, ComparisonData>;
|
||||||
|
national_averages?: NationalAverages;
|
||||||
|
benchmarks?: Benchmarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RankingItem {
|
export interface RankingItem {
|
||||||
|
|||||||
@@ -160,6 +160,12 @@ models:
|
|||||||
- name: year
|
- name: year
|
||||||
tests: [not_null, unique]
|
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
|
- name: fact_deprivation
|
||||||
description: IDACI deprivation index — one row per URN
|
description: IDACI deprivation index — one row per URN
|
||||||
columns:
|
columns:
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Mart: Computed national KS4 averages — one row per academic year.
|
||||||
|
-- Unlike fact_ks2_national_averages (official DfE figures), DfE publishes no
|
||||||
|
-- KS4 national-headline dataset we ingest yet, so these are means computed
|
||||||
|
-- across the state schools in our dataset. Computed once at build time so the
|
||||||
|
-- API never has to aggregate the full performance table per request.
|
||||||
|
-- Semantics match the API's previous per-request computation: rows where
|
||||||
|
-- attainment_8_score is non-null; per-column means ignore NULLs.
|
||||||
|
|
||||||
|
select
|
||||||
|
year,
|
||||||
|
round(avg(attainment_8_score)::numeric, 2) as attainment_8_score,
|
||||||
|
round(avg(progress_8_score)::numeric, 2) as progress_8_score,
|
||||||
|
round(avg(english_maths_standard_pass_pct)::numeric, 2) as english_maths_standard_pass_pct,
|
||||||
|
round(avg(english_maths_strong_pass_pct)::numeric, 2) as english_maths_strong_pass_pct,
|
||||||
|
round(avg(ebacc_entry_pct)::numeric, 2) as ebacc_entry_pct,
|
||||||
|
round(avg(ebacc_standard_pass_pct)::numeric, 2) as ebacc_standard_pass_pct,
|
||||||
|
round(avg(ebacc_strong_pass_pct)::numeric, 2) as ebacc_strong_pass_pct,
|
||||||
|
round(avg(ebacc_avg_score)::numeric, 2) as ebacc_avg_score,
|
||||||
|
round(avg(gcse_grade_91_pct)::numeric, 2) as gcse_grade_91_pct
|
||||||
|
from {{ ref('fact_ks4_performance') }}
|
||||||
|
where attainment_8_score is not null
|
||||||
|
group by year
|
||||||
|
order by year
|
||||||
Reference in New Issue
Block a user