fix(admissions): switch to EES content API + correct publication slug and columns
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 50s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Integrator (push) Successful in 57s
Build and Push Docker Images / Build Kestra Init (push) Successful in 33s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

The EES statistics API only exposes ~13 publications; admissions data is not
among them. Switch to the EES content API (content.explore-education-statistics.
service.gov.uk) which covers all publications.

- ees.py: add get_content_release_id() and download_release_zip_csv() that
  fetch the release ZIP and extract a named CSV member from it
- admissions.py: use corrected slug (primary-and-secondary-school-applications-
  and-offers), correct column names from actual CSV (school_urn,
  total_number_places_offered, times_put_as_1st_preference, etc.), derive
  first_preference_offers_pct from offer/application ratio, filter to primary
  schools only, keep most recent year per URN

Also includes SchoolDetailView UX redesign: parent-first section ordering,
plain-English labels, national average benchmarks, progress score colour
coding, expanded header, quick summary strip, and CSS consolidation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 10:06:36 +00:00
parent 00dca39fbd
commit b68063c9b9
5 changed files with 951 additions and 652 deletions

View File

@@ -429,10 +429,17 @@ def run_full_migration(geocode: bool = False) -> bool:
except Exception as e:
print(f" Warning: could not save geocode cache: {e}")
print("Dropping existing tables...")
Base.metadata.drop_all(bind=engine)
# Only drop the core KS2 tables — leave supplementary tables (ofsted, census,
# finance, etc.) intact so a reimport doesn't wipe integrator-populated data.
ks2_tables = ["school_results", "schools", "schema_version"]
print(f"Dropping core tables: {ks2_tables} ...")
inspector = __import__("sqlalchemy").inspect(engine)
existing = set(inspector.get_table_names())
for tname in ks2_tables:
if tname in existing:
Base.metadata.tables[tname].drop(bind=engine)
print("Creating tables...")
print("Creating all tables...")
Base.metadata.create_all(bind=engine)
print("\nLoading CSV data...")

View File

@@ -1,7 +1,8 @@
"""
School Admissions data downloader and loader.
Source: EES publication "secondary-and-primary-school-applications-and-offers"
Source: EES publication "primary-and-secondary-school-applications-and-offers"
Content API release ZIP → supporting-files/AppsandOffers_*_SchoolLevel*.csv
Update: Annual (June/July post-offer round)
"""
import argparse
@@ -14,47 +15,39 @@ import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import SUPPLEMENTARY_DIR
from db import get_session
from sources.ees import get_latest_csv_url, download_csv
from sources.ees import download_release_zip_csv
DEST_DIR = SUPPLEMENTARY_DIR / "admissions"
PUBLICATION_SLUG = "secondary-and-primary-school-applications-and-offers"
PUBLICATION_SLUG = "primary-and-secondary-school-applications-and-offers"
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", ""}
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", "Z", ""}
# Maps actual CSV column names → internal field names
COLUMN_MAP = {
"URN": "urn",
"urn": "urn",
"YEAR": "year",
"Year": "year",
# PAN
"PAN": "pan",
"published_admission_number": "pan",
"admissions_number": "pan",
# Applications
"total_applications": "total_applications",
"TAPP": "total_applications",
"applications_received": "total_applications",
# 1st preference offers
"first_preference_offers_pct": "first_preference_offers_pct",
"pct_1st_preference": "first_preference_offers_pct",
"PT1PREF": "first_preference_offers_pct",
# Oversubscription
"oversubscribed": "oversubscribed",
# School identifier
"school_urn": "urn",
# Year — e.g. 202526 → 2025
"time_period": "time_period_raw",
# PAN (places offered)
"total_number_places_offered": "pan",
# Applications (total times put as any preference)
"times_put_as_any_preferred_school": "total_applications",
# 1st-preference applications
"times_put_as_1st_preference": "times_1st_pref",
# 1st-preference offers
"number_1st_preference_offers": "offers_1st_pref",
}
def download(data_dir: Path | None = None) -> Path:
dest = (data_dir / "supplementary" / "admissions") if data_dir else DEST_DIR
dest.mkdir(parents=True, exist_ok=True)
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="primary")
if not url:
url = get_latest_csv_url(PUBLICATION_SLUG)
if not url:
raise RuntimeError("Could not find CSV URL for admissions publication")
filename = url.split("/")[-1].split("?")[0] or "admissions_latest.csv"
return download_csv(url, dest / filename)
dest_file = dest / "admissions_school_level_latest.csv"
return download_release_zip_csv(
PUBLICATION_SLUG,
dest_file,
zip_member_keyword="schoollevel",
)
def _parse_int(val) -> int | None:
@@ -90,35 +83,67 @@ def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
path = files[-1]
print(f" Admissions: loading {path} ...")
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
df = pd.read_csv(path, encoding="utf-8-sig", low_memory=False)
# Rename columns we care about
df.rename(columns=COLUMN_MAP, inplace=True)
if "urn" not in df.columns:
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
# Filter to primary schools only
if "school_phase" in df.columns:
df = df[df["school_phase"].str.lower() == "primary"]
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
df["urn"] = df["urn"].astype(int)
year = None
m = re.search(r"20(\d{2})", path.stem)
# Derive year from time_period (e.g. 202526 → 2025)
def _extract_year(val) -> int | None:
s = str(val).strip()
m = re.match(r"(\d{4})\d{2}", s)
if m:
year = int("20" + m.group(1))
return int(m.group(1))
m2 = re.search(r"20(\d{2})", s)
if m2:
return int("20" + m2.group(1))
return None
if "time_period_raw" in df.columns:
df["year"] = df["time_period_raw"].apply(_extract_year)
else:
year_m = re.search(r"20(\d{2})", path.stem)
df["year"] = int("20" + year_m.group(1)) if year_m else None
df = df.dropna(subset=["year"])
df["year"] = df["year"].astype(int)
# Keep most recent year per school (file may contain multiple years)
df = df.sort_values("year", ascending=False).groupby("urn").first().reset_index()
inserted = 0
with get_session() as session:
from sqlalchemy import text
for _, row in df.iterrows():
urn = int(row["urn"])
row_year = int(row["year"]) if "year" in df.columns and pd.notna(row.get("year")) else year
if not row_year:
continue
year = int(row["year"])
pan = _parse_int(row.get("pan"))
total_apps = _parse_int(row.get("total_applications"))
pct_1st = _parse_pct(row.get("first_preference_offers_pct"))
oversubscribed = bool(row.get("oversubscribed")) if pd.notna(row.get("oversubscribed")) else (
True if (pan and total_apps and total_apps > pan) else None
times_1st = _parse_int(row.get("times_1st_pref"))
offers_1st = _parse_int(row.get("offers_1st_pref"))
# % of 1st-preference applicants who received an offer
if times_1st and times_1st > 0 and offers_1st is not None:
pct_1st = round(offers_1st / times_1st * 100, 1)
else:
pct_1st = None
oversubscribed = (
True if (pan and times_1st and times_1st > pan) else
False if (pan and times_1st and times_1st <= pan) else
None
)
session.execute(
@@ -134,7 +159,7 @@ def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
oversubscribed = EXCLUDED.oversubscribed
"""),
{
"urn": urn, "year": row_year, "pan": pan,
"urn": urn, "year": year, "pan": pan,
"total_apps": total_apps, "pct_1st": pct_1st,
"oversubscribed": oversubscribed,
},
@@ -142,6 +167,7 @@ def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
inserted += 1
if inserted % 5000 == 0:
session.flush()
print(f" Processed {inserted} records...")
print(f" Admissions: upserted {inserted} records")
return {"inserted": inserted, "updated": 0, "skipped": 0}

View File

@@ -1,21 +1,27 @@
"""
Shared EES (Explore Education Statistics) API client.
Base URL: https://api.education.gov.uk/statistics/v1
Two APIs are available:
- Statistics API: https://api.education.gov.uk/statistics/v1 (only ~13 publications)
- Content API: https://content.explore-education-statistics.service.gov.uk/api
Covers all publications; use this for admissions and other data not in the stats API.
Download all files for a release as a ZIP from /api/releases/{id}/files.
"""
import sys
import io
import zipfile
from pathlib import Path
from typing import Optional
import requests
API_BASE = "https://api.education.gov.uk/statistics/v1"
STATS_API_BASE = "https://api.education.gov.uk/statistics/v1"
CONTENT_API_BASE = "https://content.explore-education-statistics.service.gov.uk/api"
TIMEOUT = 60
def get_publication_files(publication_slug: str) -> list[dict]:
"""Return list of data-set file descriptors for a publication."""
url = f"{API_BASE}/publications/{publication_slug}/data-set-files"
"""Return list of data-set file descriptors for a publication (statistics API)."""
url = f"{STATS_API_BASE}/publications/{publication_slug}/data-set-files"
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
return resp.json().get("results", [])
@@ -23,7 +29,7 @@ def get_publication_files(publication_slug: str) -> list[dict]:
def get_latest_csv_url(publication_slug: str, keyword: str = "") -> Optional[str]:
"""
Find the most recent CSV download URL for a publication.
Find the most recent CSV download URL for a publication (statistics API).
Optionally filter by a keyword in the file name.
"""
files = get_publication_files(publication_slug)
@@ -37,6 +43,58 @@ def get_latest_csv_url(publication_slug: str, keyword: str = "") -> Optional[str
return None
def get_content_release_id(publication_slug: str) -> str:
"""Return the latest release ID for a publication via the content API."""
url = f"{CONTENT_API_BASE}/publications/{publication_slug}/releases/latest"
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
return resp.json()["id"]
def download_release_zip_csv(
publication_slug: str,
dest_path: Path,
zip_member_keyword: str = "",
) -> Path:
"""
Download the full-release ZIP from the EES content API and extract one CSV.
If zip_member_keyword is given, the first member whose path contains that
keyword (case-insensitive) is extracted; otherwise the first .csv found is used.
Returns dest_path (the extracted CSV file).
"""
if dest_path.exists():
print(f" EES: {dest_path.name} already exists, skipping.")
return dest_path
release_id = get_content_release_id(publication_slug)
zip_url = f"{CONTENT_API_BASE}/releases/{release_id}/files"
print(f" EES: downloading release ZIP for '{publication_slug}' ...")
resp = requests.get(zip_url, timeout=300, stream=True)
resp.raise_for_status()
data = b"".join(resp.iter_content(chunk_size=65536))
with zipfile.ZipFile(io.BytesIO(data)) as z:
members = z.namelist()
target = None
kw = zip_member_keyword.lower()
for m in members:
if m.endswith(".csv") and (not kw or kw in m.lower()):
target = m
break
if not target:
raise ValueError(
f"No CSV matching '{zip_member_keyword}' in ZIP. Members: {members}"
)
print(f" EES: extracting '{target}' ...")
dest_path.parent.mkdir(parents=True, exist_ok=True)
with z.open(target) as src, open(dest_path, "wb") as dst:
dst.write(src.read())
print(f" EES: saved {dest_path} ({dest_path.stat().st_size // 1024} KB)")
return dest_path
def download_csv(url: str, dest_path: Path) -> Path:
"""Download a CSV from EES to dest_path."""
if dest_path.exists():

View File

@@ -50,7 +50,34 @@
.address {
font-size: 0.875rem;
color: var(--text-muted, #8a847a);
margin: 0;
margin: 0 0 0.75rem;
}
/* Expanded header details (headteacher, website, trust, pupils) */
.headerDetails {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
margin-top: 0.5rem;
}
.headerDetail {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
}
.headerDetail strong {
color: var(--text-primary, #1a1612);
font-weight: 600;
}
.headerDetail a {
color: var(--accent-teal, #2d7d7d);
text-decoration: none;
}
.headerDetail a:hover {
text-decoration: underline;
}
.actions {
@@ -90,6 +117,50 @@
opacity: 0.9;
}
/* Quick Summary Strip */
.summaryStrip {
display: flex;
gap: 0.625rem;
flex-wrap: wrap;
margin: 0 0 1.25rem;
}
.summaryPill {
padding: 0.35rem 0.875rem;
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.summaryPillGood {
background: #d1fae5;
color: #065f46;
}
.summaryPillWarn {
background: #fef3c7;
color: #92400e;
}
.summaryPillBad {
background: #fee2e2;
color: #991b1b;
}
/* Unified card — replaces summary / chartsSection / detailedMetrics /
absenceSection / historySection / supplementarySection / mapSection */
.card {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-soft);
}
/* Section Title */
.sectionTitle {
font-size: 1.125rem;
@@ -102,6 +173,7 @@
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.sectionTitle::before {
@@ -111,18 +183,35 @@
height: 1em;
background: var(--accent-coral, #e07256);
border-radius: 2px;
flex-shrink: 0;
}
/* Summary Section */
.summary {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-soft);
.sectionSubtitle {
font-size: 0.85rem;
color: var(--text-muted, #8a847a);
margin: -0.5rem 0 1rem;
}
/* Response count badge (used in "What Parents Say") */
.responseBadge {
font-size: 0.75rem;
font-weight: 500;
font-family: var(--font-dm-sans), sans-serif;
color: var(--text-muted, #8a847a);
background: var(--bg-secondary, #f3ede4);
padding: 0.1rem 0.5rem;
border-radius: 999px;
margin-left: auto;
}
.subSectionTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #5c564d);
margin: 1.25rem 0 0.75rem;
}
/* Metrics Grid & Cards */
.metricsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
@@ -156,21 +245,30 @@
gap: 0.25rem;
}
.metricHint {
font-size: 0.7rem;
color: var(--text-muted, #8a847a);
margin-top: 0.3rem;
font-style: italic;
}
.metricTrend {
font-size: 1rem;
color: var(--accent-teal, #2d7d7d);
}
/* Charts Section */
.chartsSection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-soft);
/* Progress score colour coding */
.progressPositive {
color: var(--accent-teal, #2d7d7d);
font-weight: 700;
}
.progressNegative {
color: var(--accent-coral, #e07256);
font-weight: 700;
}
/* Charts Section */
.chartContainer {
width: 100%;
height: 280px;
@@ -178,15 +276,6 @@
}
/* Detailed Metrics - Compact Grid Layout */
.detailedMetrics {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-soft);
}
.metricGroupsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -235,53 +324,7 @@
color: var(--accent-teal, #2d7d7d);
}
/* Absence Section */
.absenceSection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-soft);
}
.absenceGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
}
.absenceCard {
background: var(--bg-secondary, #f3ede4);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 6px;
padding: 0.75rem 1rem;
text-align: center;
}
.absenceLabel {
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
margin-bottom: 0.25rem;
font-weight: 500;
}
.absenceValue {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
}
/* Map Section */
.mapSection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-soft);
}
/* Map */
.mapContainer {
width: 100%;
height: 250px;
@@ -290,16 +333,7 @@
border: 1px solid var(--border-color, #e5dfd5);
}
/* History Section */
.historySection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-soft);
}
/* History Table */
.tableWrapper {
overflow-x: auto;
margin-top: 0.5rem;
@@ -345,7 +379,186 @@
color: var(--accent-gold, #c9a227);
}
/* Responsive Design */
/* Ofsted */
.ofstedHeader {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.ofstedGrade {
display: inline-block;
padding: 0.3rem 0.75rem;
font-size: 1rem;
font-weight: 700;
border-radius: 6px;
white-space: nowrap;
}
.ofstedGrade1 { background: rgba(45, 125, 125, 0.12); color: var(--accent-teal, #2d7d7d); }
.ofstedGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
.ofstedGrade3 { background: rgba(201, 162, 39, 0.15); color: #b8920e; }
.ofstedGrade4 { background: rgba(224, 114, 86, 0.15); color: var(--accent-coral, #e07256); }
.ofstedDate {
font-size: 0.85rem;
color: var(--text-muted, #8a847a);
}
.ofstedPrevious {
font-size: 0.8125rem;
color: var(--text-muted, #8a847a);
font-style: italic;
}
.ofstedReportLink {
font-size: 0.8125rem;
color: var(--accent-teal, #2d7d7d);
text-decoration: none;
margin-left: auto;
white-space: nowrap;
}
.ofstedReportLink:hover {
text-decoration: underline;
}
/* Parent View */
.parentViewGrid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.parentViewRow {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.parentViewLabel {
flex: 0 0 18rem;
color: var(--text-secondary, #5c564d);
font-size: 0.8125rem;
}
.parentViewBar {
flex: 1;
height: 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
overflow: hidden;
}
.parentViewFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px;
transition: width 0.4s ease;
}
.parentViewPct {
flex: 0 0 2.75rem;
text-align: right;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* Admissions badges */
.admissionsBadge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 600;
margin-top: 0.75rem;
}
.admissionsBadgeWarn {
background: rgba(201, 162, 39, 0.15);
color: #b8920e;
}
.admissionsBadgeGood {
background: rgba(60, 140, 60, 0.12);
color: #3c8c3c;
}
/* Deprivation dot scale */
.deprivationDots {
display: flex;
gap: 0.375rem;
margin: 0.75rem 0 0.5rem;
align-items: center;
}
.deprivationDot {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--bg-secondary, #f3ede4);
border: 2px solid var(--border-color, #e5dfd5);
flex-shrink: 0;
}
.deprivationDotFilled {
background: var(--accent-teal, #2d7d7d);
border-color: var(--accent-teal, #2d7d7d);
}
.deprivationDesc {
font-size: 0.875rem;
color: var(--text-secondary, #5c564d);
line-height: 1.5;
margin: 0;
}
.deprivationScaleLabel {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: var(--text-muted, #8a847a);
margin-top: 0.25rem;
}
/* Progress note */
.progressNote {
margin-top: 0.75rem;
font-size: 0.8rem;
color: var(--text-muted);
font-style: italic;
}
/* Back navigation */
.backNav {
padding: 1rem var(--page-padding, 2rem);
padding-bottom: 0;
}
.backButton {
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
padding: 0.375rem 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
transition: color var(--transition);
}
.backButton:hover {
color: var(--text-primary);
}
/* ── Responsive ──────────────────────────────────────── */
@media (max-width: 768px) {
.headerContent {
flex-direction: column;
@@ -396,148 +609,27 @@
}
}
.backNav {
padding: 1rem var(--page-padding, 2rem);
padding-bottom: 0;
}
.backButton {
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
padding: 0.375rem 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
transition: color var(--transition);
}
.backButton:hover {
color: var(--text-primary);
}
.progressNote {
margin-top: 0.75rem;
font-size: 0.8rem;
color: var(--text-muted);
font-style: italic;
}
/* ── Supplementary Data Sections ──────────────────────── */
.supplementarySection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1.25rem 1.5rem;
}
.supplementarySubtitle {
font-size: 0.85rem;
color: var(--text-muted, #8a847a);
margin-bottom: 1rem;
}
.subSectionTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #5c564d);
margin: 1.25rem 0 0.75rem;
}
/* Ofsted */
.ofstedHeader {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.ofstedGrade {
display: inline-block;
padding: 0.3rem 0.75rem;
font-size: 1rem;
font-weight: 700;
border-radius: 6px;
white-space: nowrap;
}
.ofstedGrade1 { background: rgba(45, 125, 125, 0.12); color: var(--accent-teal, #2d7d7d); }
.ofstedGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
.ofstedGrade3 { background: rgba(201, 162, 39, 0.15); color: #b8920e; }
.ofstedGrade4 { background: rgba(224, 114, 86, 0.15); color: var(--accent-coral, #e07256); }
.ofstedDate {
font-size: 0.85rem;
color: var(--text-muted, #8a847a);
}
.ofstedType {
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
margin-top: 0.5rem;
font-style: italic;
}
/* Parent View */
.parentViewGrid {
display: flex;
@media (max-width: 480px) {
.parentViewRow {
flex-direction: column;
gap: 0.5rem;
}
.parentViewRow {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.parentViewLabel {
flex: 0 0 18rem;
color: var(--text-secondary, #5c564d);
font-size: 0.8125rem;
}
.parentViewBar {
flex: 1;
height: 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
overflow: hidden;
}
.parentViewFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px;
transition: width 0.4s ease;
}
.parentViewPct {
flex: 0 0 2.75rem;
text-align: right;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* Metric hint (small label below metricValue) */
.metricHint {
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
margin-top: 0.25rem;
font-style: italic;
}
/* ── Mobile ──────────────────────────────────────────── */
@media (max-width: 640px) {
.supplementarySection {
padding: 1rem;
align-items: flex-start;
gap: 0.25rem;
}
.parentViewLabel {
flex: 0 0 10rem;
flex: none;
max-width: 100%;
}
.parentViewBar {
width: 100%;
}
.parentViewPct {
flex: none;
}
.card {
padding: 1rem;
}
}

View File

@@ -15,13 +15,41 @@ import type {
SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance,
} from '@/lib/types';
import { formatPercentage, formatProgress, calculateTrend } from '@/lib/utils';
import { formatPercentage, formatProgress } from '@/lib/utils';
import styles from './SchoolDetailView.module.css';
const OFSTED_LABELS: Record<number, string> = {
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
};
// 2023 national averages for context
const NATIONAL_AVG = {
rwm_expected: 60,
rwm_high: 8,
reading_expected: 73,
writing_expected: 71,
maths_expected: 73,
phonics_yr1: 79,
overall_absence: 6.7,
persistent_absence: 22,
class_size: 27,
per_pupil_spend: 6000,
};
function pillClass(pct: number | null | undefined): string {
if (pct == null) return styles.summaryPillWarn;
if (pct >= 80) return styles.summaryPillGood;
if (pct >= 60) return styles.summaryPillWarn;
return styles.summaryPillBad;
}
function progressClass(val: number | null | undefined): string {
if (val == null) return '';
if (val > 0.1) return styles.progressPositive;
if (val < -0.1) return styles.progressNegative;
return '';
}
interface SchoolDetailViewProps {
schoolInfo: School;
yearlyData: SchoolResult[];
@@ -44,10 +72,8 @@ export function SchoolDetailView({
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
// Get latest results
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// Handle add/remove from comparison
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
@@ -56,6 +82,13 @@ export function SchoolDetailView({
}
};
// Deprivation plain-English description
const deprivationDesc = (decile: number) => {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
};
return (
<div className={styles.container}>
{/* Back Navigation */}
@@ -63,29 +96,52 @@ export function SchoolDetailView({
<button onClick={() => router.back()} className={styles.backButton}> Back</button>
</div>
{/* Header Section */}
{/* Header */}
<header className={styles.header}>
<div className={styles.headerContent}>
<div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.meta}>
{schoolInfo.local_authority && (
<span className={styles.metaItem}>
{schoolInfo.local_authority}
</span>
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
)}
{schoolInfo.school_type && (
<span className={styles.metaItem}>
{schoolInfo.school_type}
</span>
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)}
</div>
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}
{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
</p>
)}
<div className={styles.headerDetails}>
{schoolInfo.headteacher_name && (
<span className={styles.headerDetail}>
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
</span>
)}
{schoolInfo.website && (
<span className={styles.headerDetail}>
<a href={schoolInfo.website} target="_blank" rel="noopener noreferrer">
School website
</a>
</span>
)}
{latestResults?.total_pupils != null && (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {latestResults.total_pupils.toLocaleString()}
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
</span>
)}
{schoolInfo.trust_name && (
<span className={styles.headerDetail}>
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
</div>
</div>
<div className={styles.actions}>
<button
@@ -98,78 +154,407 @@ export function SchoolDetailView({
</div>
</header>
{/* Latest Results Summary */}
{latestResults && (
<section className={styles.summary}>
{/* Quick Summary Strip */}
{(ofsted || parentView) && (
<div className={styles.summaryStrip}>
{ofsted?.overall_effectiveness != null && (
<span className={`${styles.summaryPill} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
Ofsted: {OFSTED_LABELS[ofsted.overall_effectiveness]}
</span>
)}
{parentView?.q_happy_pct != null && (
<span className={`${styles.summaryPill} ${pillClass(parentView.q_happy_pct)}`}>
{Math.round(parentView.q_happy_pct)}% say child is happy
</span>
)}
{parentView?.q_safe_pct != null && (
<span className={`${styles.summaryPill} ${pillClass(parentView.q_safe_pct)}`}>
{Math.round(parentView.q_safe_pct)}% say child is safe
</span>
)}
{parentView?.q_recommend_pct != null && (
<span className={`${styles.summaryPill} ${pillClass(parentView.q_recommend_pct)}`}>
{Math.round(parentView.q_recommend_pct)}% would recommend
</span>
)}
</div>
)}
{/* Ofsted Rating */}
{ofsted && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>
Latest Results ({latestResults.year})
Ofsted Rating
{ofsted.inspection_date && (
<span className={styles.ofstedDate}>
Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
)}
<a
href={`https://reports.ofsted.gov.uk/provider/21/${schoolInfo.urn}`}
target="_blank"
rel="noopener noreferrer"
className={styles.ofstedReportLink}
>
Full report
</a>
</h2>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
</span>
{ofsted.previous_overall != null &&
ofsted.previous_overall !== ofsted.overall_effectiveness && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</div>
<div className={styles.metricsGrid}>
{[
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
{ label: 'School Leadership', value: ofsted.leadership_management },
...(ofsted.early_years_provision != null
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
: []),
].map(({ label, value }) => value != null && (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
</div>
))}
</div>
</section>
)}
{/* What Parents Say */}
{parentView && parentView.total_responses != null && parentView.total_responses > 0 && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>
What Parents Say
<span className={styles.responseBadge}>
{parentView.total_responses.toLocaleString()} responses
</span>
</h2>
<p className={styles.sectionSubtitle}>
From the Ofsted Parent View survey parents share their experience of this school.
</p>
<div className={styles.parentViewGrid}>
{[
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
].filter(q => q.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.parentViewRow}>
<span className={styles.parentViewLabel}>{label}</span>
<div className={styles.parentViewBar}>
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
</div>
))}
</div>
</section>
)}
{/* SATs Results */}
{latestResults && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>SATs Results ({latestResults.year})</h2>
<p className={styles.sectionSubtitle}>
End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.
</p>
<div className={styles.metricsGrid}>
{latestResults.rwm_expected_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
RWM Expected Standard
</div>
<div className={styles.metricValue}>
{formatPercentage(latestResults.rwm_expected_pct)}
</div>
<div className={styles.metricLabel}>Reading, Writing & Maths combined</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_expected_pct)}</div>
<div className={styles.metricHint}>National avg: {NATIONAL_AVG.rwm_expected}%</div>
</div>
)}
{latestResults.rwm_high_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
RWM Higher Standard
</div>
<div className={styles.metricValue}>
{formatPercentage(latestResults.rwm_high_pct)}
</div>
<div className={styles.metricLabel}>Exceeding expected level (RWM)</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_high_pct)}</div>
<div className={styles.metricHint}>National avg: {NATIONAL_AVG.rwm_high}%</div>
</div>
)}
{latestResults.reading_progress !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Reading Progress
</div>
<div className={styles.metricValue}>
<div className={styles.metricLabel}>Reading Progress</div>
<div className={`${styles.metricValue} ${progressClass(latestResults.reading_progress)}`}>
{formatProgress(latestResults.reading_progress)}
</div>
</div>
)}
{latestResults.writing_progress !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Writing Progress
</div>
<div className={styles.metricValue}>
<div className={styles.metricLabel}>Writing Progress</div>
<div className={`${styles.metricValue} ${progressClass(latestResults.writing_progress)}`}>
{formatProgress(latestResults.writing_progress)}
</div>
</div>
)}
{latestResults.maths_progress !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Maths Progress
</div>
<div className={styles.metricValue}>
<div className={styles.metricLabel}>Maths Progress</div>
<div className={`${styles.metricValue} ${progressClass(latestResults.maths_progress)}`}>
{formatProgress(latestResults.maths_progress)}
</div>
</div>
)}
</div>
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
<p className={styles.progressNote}>Progress scores: 0 = national average. Positive = above average, negative = below average.</p>
<p className={styles.progressNote}>
Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average.
</p>
)}
</section>
)}
{/* Map */}
{/* Detailed Subject Breakdown */}
{latestResults && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Subject Breakdown ({latestResults.year})</h2>
<div className={styles.metricGroupsGrid}>
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>Reading</h3>
<div className={styles.metricTable}>
{latestResults.reading_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected level</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_expected_pct)}</span>
</div>
)}
{latestResults.reading_high_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Exceeding</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_high_pct)}</span>
</div>
)}
{latestResults.reading_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress score</span>
<span className={styles.metricValue}>{formatProgress(latestResults.reading_progress)}</span>
</div>
)}
{latestResults.reading_avg_score !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Average score</span>
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
</div>
)}
</div>
</div>
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>Writing</h3>
<div className={styles.metricTable}>
{latestResults.writing_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected level</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_expected_pct)}</span>
</div>
)}
{latestResults.writing_high_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Exceeding</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_high_pct)}</span>
</div>
)}
{latestResults.writing_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress score</span>
<span className={styles.metricValue}>{formatProgress(latestResults.writing_progress)}</span>
</div>
)}
</div>
</div>
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>Maths</h3>
<div className={styles.metricTable}>
{latestResults.maths_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected level</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_expected_pct)}</span>
</div>
)}
{latestResults.maths_high_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Exceeding</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_high_pct)}</span>
</div>
)}
{latestResults.maths_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress score</span>
<span className={styles.metricValue}>{formatProgress(latestResults.maths_progress)}</span>
</div>
)}
{latestResults.maths_avg_score !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Average score</span>
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
</div>
)}
</div>
</div>
</div>
</section>
)}
{/* Year 1 Phonics */}
{phonics && phonics.year1_phonics_pct != null && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Year 1 Phonics ({phonics.year})</h2>
<p className={styles.sectionSubtitle}>
Phonics is a key early reading skill. Children are tested at the end of Year 1.
</p>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Passed the phonics check</div>
<div className={styles.metricValue}>{formatPercentage(phonics.year1_phonics_pct)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.phonics_yr1}%</div>
</div>
{phonics.year2_phonics_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 2 pupils who retook and passed</div>
<div className={styles.metricValue}>{formatPercentage(phonics.year2_phonics_pct)}</div>
</div>
)}
</div>
</section>
)}
{/* School Life */}
{(absenceData || census?.class_size_avg != null) && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>School Life</h2>
<div className={styles.metricsGrid}>
{census?.class_size_avg != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Average class size</div>
<div className={styles.metricValue}>{census.class_size_avg.toFixed(1)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.class_size} pupils</div>
</div>
)}
{absenceData?.overall_absence_rate != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Days missed (overall absence)</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.overall_absence}%</div>
</div>
)}
{absenceData?.persistent_absence_rate != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Regularly missing school</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.persistent_absence}%. Missing 10%+ of sessions.</div>
</div>
)}
</div>
</section>
)}
{/* How Hard to Get In */}
{admissions && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>How Hard to Get Into This School</h2>
<div className={styles.metricsGrid}>
{admissions.published_admission_number != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 3 places per year</div>
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Applications received</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offers_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Families who got their first-choice</div>
<div className={styles.metricValue}>{admissions.first_preference_offers_pct}%</div>
</div>
)}
</div>
{admissions.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.admissionsBadgeWarn : styles.admissionsBadgeGood}`}>
{admissions.oversubscribed
? '⚠ More applications than places last year'
: '✓ Places were available last year'}
</div>
)}
</section>
)}
{/* Pupils & Inclusion */}
{(latestResults || senDetail) && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Pupils &amp; Inclusion</h2>
<div className={styles.metricsGrid}>
{latestResults?.disadvantaged_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Eligible for pupil premium</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.disadvantaged_pct)}</div>
<div className={styles.metricHint}>Pupils from disadvantaged backgrounds</div>
</div>
)}
{latestResults?.eal_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>English as an additional language</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.eal_pct)}</div>
</div>
)}
{latestResults?.sen_support_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Pupils with additional needs (SEN support)</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
</div>
)}
</div>
{senDetail && (
<>
<h3 className={styles.subSectionTitle}>Types of additional needs supported</h3>
<p className={styles.sectionSubtitle}>
What proportion of pupils with additional needs have each type of support need.
</p>
<div className={styles.metricsGrid}>
{[
{ label: 'Speech & Language', pct: senDetail.primary_need_speech_pct },
{ label: 'Autism (ASD)', pct: senDetail.primary_need_autism_pct },
{ label: 'Learning Difficulties', pct: senDetail.primary_need_mld_pct },
{ label: 'Specific Learning (e.g. Dyslexia)', pct: senDetail.primary_need_spld_pct },
{ label: 'Social, Emotional & Mental Health', pct: senDetail.primary_need_semh_pct },
{ label: 'Physical / Sensory', pct: senDetail.primary_need_physical_pct },
].filter(n => n.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={styles.metricValue}>{pct}%</div>
</div>
))}
</div>
</>
)}
</section>
)}
{/* Location */}
{schoolInfo.latitude && schoolInfo.longitude && (
<section className={styles.mapSection}>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Location</h2>
<div className={styles.mapContainer}>
<SchoolMap
@@ -181,10 +566,60 @@ export function SchoolDetailView({
</section>
)}
{/* Performance Over Time */}
{/* Local Area Context */}
{deprivation && deprivation.idaci_decile != null && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Local Area Context</h2>
<div className={styles.deprivationDots}>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
title={`Decile ${i + 1}`}
/>
))}
</div>
<div className={styles.deprivationScaleLabel}>
<span>Most deprived</span>
<span>Least deprived</span>
</div>
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile)}</p>
</section>
)}
{/* Finances */}
{finance && finance.per_pupil_spend != null && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>School Finances ({finance.year})</h2>
<p className={styles.sectionSubtitle}>
Per-pupil spending shows how much the school has to spend on each child&apos;s education.
</p>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Total spend per pupil per year</div>
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend).toLocaleString()}</div>
<div className={styles.metricHint}>National avg: ~£{NATIONAL_AVG.per_pupil_spend.toLocaleString()}</div>
</div>
{finance.teacher_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
</div>
)}
{finance.staff_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
</div>
)}
</div>
</section>
)}
{/* Results Over Time */}
{yearlyData.length > 0 && (
<section className={styles.chartsSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Results Over Time</h2>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
@@ -194,133 +629,17 @@ export function SchoolDetailView({
</section>
)}
{/* Detailed Metrics */}
{latestResults && (
<section className={styles.detailedMetrics}>
<h2 className={styles.sectionTitle}>Detailed Metrics</h2>
<div className={styles.metricGroupsGrid}>
{/* Reading Metrics */}
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>Reading</h3>
<div className={styles.metricTable}>
{latestResults.reading_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_expected_pct)}</span>
</div>
)}
{latestResults.reading_high_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Higher</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_high_pct)}</span>
</div>
)}
{latestResults.reading_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress</span>
<span className={styles.metricValue}>{formatProgress(latestResults.reading_progress)}</span>
</div>
)}
{latestResults.reading_avg_score !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Avg Score</span>
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
</div>
)}
</div>
</div>
{/* Writing Metrics */}
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>Writing</h3>
<div className={styles.metricTable}>
{latestResults.writing_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_expected_pct)}</span>
</div>
)}
{latestResults.writing_high_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Higher</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_high_pct)}</span>
</div>
)}
{latestResults.writing_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress</span>
<span className={styles.metricValue}>{formatProgress(latestResults.writing_progress)}</span>
</div>
)}
</div>
</div>
{/* Maths Metrics */}
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>Maths</h3>
<div className={styles.metricTable}>
{latestResults.maths_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_expected_pct)}</span>
</div>
)}
{latestResults.maths_high_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Higher</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_high_pct)}</span>
</div>
)}
{latestResults.maths_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress</span>
<span className={styles.metricValue}>{formatProgress(latestResults.maths_progress)}</span>
</div>
)}
{latestResults.maths_avg_score !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Avg Score</span>
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
</div>
)}
</div>
</div>
</div>
</section>
)}
{/* Absence Data */}
{absenceData && (
<section className={styles.absenceSection}>
<h2 className={styles.sectionTitle}>Absence Data</h2>
<div className={styles.absenceGrid}>
{absenceData.overall_absence_rate !== null && (
<div className={styles.absenceCard}>
<div className={styles.absenceLabel}>Overall Absence Rate</div>
<div className={styles.absenceValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
</div>
)}
{absenceData.persistent_absence_rate !== null && (
<div className={styles.absenceCard}>
<div className={styles.absenceLabel}>Persistent Absence</div>
<div className={styles.absenceValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
</div>
)}
</div>
</section>
)}
{/* All Years Data Table */}
{yearlyData.length > 0 && (
<section className={styles.historySection}>
<h2 className={styles.sectionTitle}>Historical Data</h2>
{/* Historical Data Table */}
{yearlyData.length > 1 && (
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Year-by-Year Summary</h2>
<div className={styles.tableWrapper}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>Year</th>
<th>RWM Expected</th>
<th>RWM Higher</th>
<th>Reading, Writing & Maths (expected %)</th>
<th>Exceeding expected (%)</th>
<th>Reading Progress</th>
<th>Writing Progress</th>
<th>Maths Progress</th>
@@ -342,209 +661,6 @@ export function SchoolDetailView({
</div>
</section>
)}
{/* Ofsted Section */}
{ofsted && (
<section className={styles.supplementarySection}>
<h2 className={styles.sectionTitle}>Ofsted Inspection</h2>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
</span>
{ofsted.inspection_date && (
<span className={styles.ofstedDate}>
Inspected: {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span>
)}
</div>
<div className={styles.metricsGrid}>
{[
{ label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
{ label: 'Personal Development', value: ofsted.personal_development },
{ label: 'Leadership & Management', value: ofsted.leadership_management },
...(ofsted.early_years_provision != null ? [{ label: 'Early Years', value: ofsted.early_years_provision }] : []),
].map(({ label, value }) => value != null && (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
</div>
))}
</div>
{ofsted.inspection_type && (
<p className={styles.ofstedType}>{ofsted.inspection_type}</p>
)}
</section>
)}
{/* What Parents Think */}
{parentView && parentView.total_responses != null && parentView.total_responses > 0 && (
<section className={styles.supplementarySection}>
<h2 className={styles.sectionTitle}>What Parents Think</h2>
<p className={styles.supplementarySubtitle}>
Based on {parentView.total_responses.toLocaleString()} parent responses to the Ofsted Parent View survey.
</p>
<div className={styles.parentViewGrid}>
{[
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
{ label: 'School looks after wellbeing', pct: parentView.q_wellbeing_pct },
{ label: 'Led and managed effectively', pct: parentView.q_leadership_pct },
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
].filter(q => q.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.parentViewRow}>
<span className={styles.parentViewLabel}>{label}</span>
<div className={styles.parentViewBar}>
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.parentViewPct}>{pct}%</span>
</div>
))}
</div>
</section>
)}
{/* Admissions */}
{admissions && (
<section className={styles.supplementarySection}>
<h2 className={styles.sectionTitle}>Admissions ({admissions.year})</h2>
<div className={styles.metricsGrid}>
{admissions.published_admission_number != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Places available</div>
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Applications received</div>
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
</div>
)}
{admissions.first_preference_offers_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Got first choice</div>
<div className={styles.metricValue}>{admissions.first_preference_offers_pct}%</div>
</div>
)}
{admissions.oversubscribed != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Oversubscribed</div>
<div className={styles.metricValue}>{admissions.oversubscribed ? 'Yes' : 'No'}</div>
</div>
)}
</div>
</section>
)}
{/* Pupils & Inclusion (Census + SEN) */}
{(census || senDetail) && (
<section className={styles.supplementarySection}>
<h2 className={styles.sectionTitle}>Pupils &amp; Inclusion</h2>
<div className={styles.metricsGrid}>
{census?.class_size_avg != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Average class size</div>
<div className={styles.metricValue}>{census.class_size_avg.toFixed(1)}</div>
</div>
)}
</div>
{senDetail && (
<>
<h3 className={styles.subSectionTitle}>Primary SEN Needs (latest year)</h3>
<div className={styles.metricsGrid}>
{[
{ label: 'Speech & Language', pct: senDetail.primary_need_speech_pct },
{ label: 'Autism (ASD)', pct: senDetail.primary_need_autism_pct },
{ label: 'Learning Difficulties', pct: senDetail.primary_need_mld_pct },
{ label: 'Specific Learning (Dyslexia etc.)', pct: senDetail.primary_need_spld_pct },
{ label: 'Social, Emotional & Mental Health', pct: senDetail.primary_need_semh_pct },
{ label: 'Physical / Sensory', pct: senDetail.primary_need_physical_pct },
].filter(n => n.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={styles.metricValue}>{pct}%</div>
</div>
))}
</div>
</>
)}
</section>
)}
{/* Year 1 Phonics */}
{phonics && phonics.year1_phonics_pct != null && (
<section className={styles.supplementarySection}>
<h2 className={styles.sectionTitle}>Year 1 Phonics ({phonics.year})</h2>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Reached expected standard</div>
<div className={styles.metricValue}>{formatPercentage(phonics.year1_phonics_pct)}</div>
</div>
{phonics.year2_phonics_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 2 (re-takers) standard</div>
<div className={styles.metricValue}>{formatPercentage(phonics.year2_phonics_pct)}</div>
</div>
)}
</div>
</section>
)}
{/* Deprivation Context */}
{deprivation && deprivation.idaci_decile != null && (
<section className={styles.supplementarySection}>
<h2 className={styles.sectionTitle}>Deprivation Context</h2>
<div className={styles.metricsGrid}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Area deprivation decile</div>
<div className={styles.metricValue}>{deprivation.idaci_decile} / 10</div>
<div className={styles.metricHint}>
1 = most deprived, 10 = least deprived
</div>
</div>
{deprivation.idaci_score != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>IDACI score</div>
<div className={styles.metricValue}>{deprivation.idaci_score.toFixed(3)}</div>
</div>
)}
</div>
</section>
)}
{/* Finances */}
{finance && finance.per_pupil_spend != null && (
<section className={styles.supplementarySection}>
<h2 className={styles.sectionTitle}>Finances ({finance.year})</h2>
<div className={styles.metricsGrid}>
{finance.per_pupil_spend != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Spend per pupil</div>
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend).toLocaleString()}</div>
</div>
)}
{finance.teacher_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>Teacher costs</div>
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}% of budget</div>
</div>
)}
{finance.staff_cost_pct != null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>All staff costs</div>
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}% of budget</div>
</div>
)}
</div>
</section>
)}
</div>
);
}