From b68063c9b97db40957b2b2d288c990f43116c7ed Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 25 Mar 2026 10:06:36 +0000 Subject: [PATCH] fix(admissions): switch to EES content API + correct publication slug and columns 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 --- backend/migration.py | 13 +- integrator/scripts/sources/admissions.py | 112 ++- integrator/scripts/sources/ees.py | 70 +- .../components/SchoolDetailView.module.css | 540 ++++++----- nextjs-app/components/SchoolDetailView.tsx | 868 ++++++++++-------- 5 files changed, 951 insertions(+), 652 deletions(-) diff --git a/backend/migration.py b/backend/migration.py index 071d38d..b3f58d2 100644 --- a/backend/migration.py +++ b/backend/migration.py @@ -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...") diff --git a/integrator/scripts/sources/admissions.py b/integrator/scripts/sources/admissions.py index ff38548..159839e 100644 --- a/integrator/scripts/sources/admissions.py +++ b/integrator/scripts/sources/admissions.py @@ -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) - if m: - year = int("20" + m.group(1)) + # 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: + 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} diff --git a/integrator/scripts/sources/ees.py b/integrator/scripts/sources/ees.py index d610daa..a614466 100644 --- a/integrator/scripts/sources/ees.py +++ b/integrator/scripts/sources/ees.py @@ -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(): diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index d4ea91f..14c434b 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -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; - 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; +@media (max-width: 480px) { + .parentViewRow { + flex-direction: column; + 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; } } diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index d1f8d49..d9afa6e 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -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 = { 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 (
{/* Back Navigation */} @@ -63,29 +96,52 @@ export function SchoolDetailView({
- {/* Header Section */} + {/* Header */}

{schoolInfo.school_name}

{schoolInfo.local_authority && ( - - {schoolInfo.local_authority} - + {schoolInfo.local_authority} )} {schoolInfo.school_type && ( - - {schoolInfo.school_type} - + {schoolInfo.school_type} + )} + {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( + {schoolInfo.gender}'s school )}
{schoolInfo.address && (

- {schoolInfo.address} - {schoolInfo.postcode && `, ${schoolInfo.postcode}`} + {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}

)} +
+ {schoolInfo.headteacher_name && ( + + Headteacher: {schoolInfo.headteacher_name} + + )} + {schoolInfo.website && ( + + + School website ↗ + + + )} + {latestResults?.total_pupils != null && ( + + Pupils: {latestResults.total_pupils.toLocaleString()} + {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} + + )} + {schoolInfo.trust_name && ( + + Part of {schoolInfo.trust_name} + + )} +
- {/* Latest Results Summary */} - {latestResults && ( -
+ {/* Quick Summary Strip */} + {(ofsted || parentView) && ( +
+ {ofsted?.overall_effectiveness != null && ( + + Ofsted: {OFSTED_LABELS[ofsted.overall_effectiveness]} + + )} + {parentView?.q_happy_pct != null && ( + + {Math.round(parentView.q_happy_pct)}% say child is happy + + )} + {parentView?.q_safe_pct != null && ( + + {Math.round(parentView.q_safe_pct)}% say child is safe + + )} + {parentView?.q_recommend_pct != null && ( + + {Math.round(parentView.q_recommend_pct)}% would recommend + + )} +
+ )} + + {/* Ofsted Rating */} + {ofsted && ( +

- Latest Results ({latestResults.year}) + Ofsted Rating + {ofsted.inspection_date && ( + + Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} + + )} + + Full report ↗ +

+
+ + {ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'} + + {ofsted.previous_overall != null && + ofsted.previous_overall !== ofsted.overall_effectiveness && ( + + Previously: {OFSTED_LABELS[ofsted.previous_overall]} + + )} +
+
+ {[ + { 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 && ( +
+
{label}
+
+ {OFSTED_LABELS[value]} +
+
+ ))} +
+
+ )} + + {/* What Parents Say */} + {parentView && parentView.total_responses != null && parentView.total_responses > 0 && ( +
+

+ What Parents Say + + {parentView.total_responses.toLocaleString()} responses + +

+

+ From the Ofsted Parent View survey — parents share their experience of this school. +

+
+ {[ + { 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 }) => ( +
+ {label} +
+
+
+ {Math.round(pct!)}% +
+ ))} +
+
+ )} + + {/* SATs Results */} + {latestResults && ( +
+

SATs Results ({latestResults.year})

+

+ End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison. +

{latestResults.rwm_expected_pct !== null && (
-
- RWM Expected Standard -
-
- {formatPercentage(latestResults.rwm_expected_pct)} -
+
Reading, Writing & Maths combined
+
{formatPercentage(latestResults.rwm_expected_pct)}
+
National avg: {NATIONAL_AVG.rwm_expected}%
)} - {latestResults.rwm_high_pct !== null && (
-
- RWM Higher Standard -
-
- {formatPercentage(latestResults.rwm_high_pct)} -
+
Exceeding expected level (RWM)
+
{formatPercentage(latestResults.rwm_high_pct)}
+
National avg: {NATIONAL_AVG.rwm_high}%
)} - {latestResults.reading_progress !== null && (
-
- Reading Progress -
-
+
Reading Progress
+
{formatProgress(latestResults.reading_progress)}
)} - {latestResults.writing_progress !== null && (
-
- Writing Progress -
-
+
Writing Progress
+
{formatProgress(latestResults.writing_progress)}
)} - {latestResults.maths_progress !== null && (
-
- Maths Progress -
-
+
Maths Progress
+
{formatProgress(latestResults.maths_progress)}
)} -
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && ( -

Progress scores: 0 = national average. Positive = above average, negative = below average.

+

+ Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average. +

)}
)} - {/* Map */} + {/* Detailed Subject Breakdown */} + {latestResults && ( +
+

Subject Breakdown ({latestResults.year})

+
+
+

Reading

+
+ {latestResults.reading_expected_pct !== null && ( +
+ Expected level + {formatPercentage(latestResults.reading_expected_pct)} +
+ )} + {latestResults.reading_high_pct !== null && ( +
+ Exceeding + {formatPercentage(latestResults.reading_high_pct)} +
+ )} + {latestResults.reading_progress !== null && ( +
+ Progress score + {formatProgress(latestResults.reading_progress)} +
+ )} + {latestResults.reading_avg_score !== null && ( +
+ Average score + {latestResults.reading_avg_score.toFixed(1)} +
+ )} +
+
+ +
+

Writing

+
+ {latestResults.writing_expected_pct !== null && ( +
+ Expected level + {formatPercentage(latestResults.writing_expected_pct)} +
+ )} + {latestResults.writing_high_pct !== null && ( +
+ Exceeding + {formatPercentage(latestResults.writing_high_pct)} +
+ )} + {latestResults.writing_progress !== null && ( +
+ Progress score + {formatProgress(latestResults.writing_progress)} +
+ )} +
+
+ +
+

Maths

+
+ {latestResults.maths_expected_pct !== null && ( +
+ Expected level + {formatPercentage(latestResults.maths_expected_pct)} +
+ )} + {latestResults.maths_high_pct !== null && ( +
+ Exceeding + {formatPercentage(latestResults.maths_high_pct)} +
+ )} + {latestResults.maths_progress !== null && ( +
+ Progress score + {formatProgress(latestResults.maths_progress)} +
+ )} + {latestResults.maths_avg_score !== null && ( +
+ Average score + {latestResults.maths_avg_score.toFixed(1)} +
+ )} +
+
+
+
+ )} + + {/* Year 1 Phonics */} + {phonics && phonics.year1_phonics_pct != null && ( +
+

Year 1 Phonics ({phonics.year})

+

+ Phonics is a key early reading skill. Children are tested at the end of Year 1. +

+
+
+
Passed the phonics check
+
{formatPercentage(phonics.year1_phonics_pct)}
+
National avg: ~{NATIONAL_AVG.phonics_yr1}%
+
+ {phonics.year2_phonics_pct != null && ( +
+
Year 2 pupils who retook and passed
+
{formatPercentage(phonics.year2_phonics_pct)}
+
+ )} +
+
+ )} + + {/* School Life */} + {(absenceData || census?.class_size_avg != null) && ( +
+

School Life

+
+ {census?.class_size_avg != null && ( +
+
Average class size
+
{census.class_size_avg.toFixed(1)}
+
National avg: ~{NATIONAL_AVG.class_size} pupils
+
+ )} + {absenceData?.overall_absence_rate != null && ( +
+
Days missed (overall absence)
+
{formatPercentage(absenceData.overall_absence_rate)}
+
National avg: ~{NATIONAL_AVG.overall_absence}%
+
+ )} + {absenceData?.persistent_absence_rate != null && ( +
+
Regularly missing school
+
{formatPercentage(absenceData.persistent_absence_rate)}
+
National avg: ~{NATIONAL_AVG.persistent_absence}%. Missing 10%+ of sessions.
+
+ )} +
+
+ )} + + {/* How Hard to Get In */} + {admissions && ( +
+

How Hard to Get Into This School

+
+ {admissions.published_admission_number != null && ( +
+
Year 3 places per year
+
{admissions.published_admission_number}
+
+ )} + {admissions.total_applications != null && ( +
+
Applications received
+
{admissions.total_applications.toLocaleString()}
+
+ )} + {admissions.first_preference_offers_pct != null && ( +
+
Families who got their first-choice
+
{admissions.first_preference_offers_pct}%
+
+ )} +
+ {admissions.oversubscribed != null && ( +
+ {admissions.oversubscribed + ? '⚠ More applications than places last year' + : '✓ Places were available last year'} +
+ )} +
+ )} + + {/* Pupils & Inclusion */} + {(latestResults || senDetail) && ( +
+

Pupils & Inclusion

+
+ {latestResults?.disadvantaged_pct != null && ( +
+
Eligible for pupil premium
+
{formatPercentage(latestResults.disadvantaged_pct)}
+
Pupils from disadvantaged backgrounds
+
+ )} + {latestResults?.eal_pct != null && ( +
+
English as an additional language
+
{formatPercentage(latestResults.eal_pct)}
+
+ )} + {latestResults?.sen_support_pct != null && ( +
+
Pupils with additional needs (SEN support)
+
{formatPercentage(latestResults.sen_support_pct)}
+
+ )} +
+ {senDetail && ( + <> +

Types of additional needs supported

+

+ What proportion of pupils with additional needs have each type of support need. +

+
+ {[ + { 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 }) => ( +
+
{label}
+
{pct}%
+
+ ))} +
+ + )} +
+ )} + + {/* Location */} {schoolInfo.latitude && schoolInfo.longitude && ( -
+

Location

)} - {/* Performance Over Time */} + {/* Local Area Context */} + {deprivation && deprivation.idaci_decile != null && ( +
+

Local Area Context

+
+ {Array.from({ length: 10 }, (_, i) => ( +
+ ))} +
+
+ Most deprived + Least deprived +
+

{deprivationDesc(deprivation.idaci_decile)}

+
+ )} + + {/* Finances */} + {finance && finance.per_pupil_spend != null && ( +
+

School Finances ({finance.year})

+

+ Per-pupil spending shows how much the school has to spend on each child's education. +

+
+
+
Total spend per pupil per year
+
£{Math.round(finance.per_pupil_spend).toLocaleString()}
+
National avg: ~£{NATIONAL_AVG.per_pupil_spend.toLocaleString()}
+
+ {finance.teacher_cost_pct != null && ( +
+
Share of budget spent on teachers
+
{finance.teacher_cost_pct.toFixed(1)}%
+
+ )} + {finance.staff_cost_pct != null && ( +
+
Share of budget spent on all staff
+
{finance.staff_cost_pct.toFixed(1)}%
+
+ )} +
+
+ )} + + {/* Results Over Time */} {yearlyData.length > 0 && ( -
-

Performance Over Time

+
+

Results Over Time

)} - {/* Detailed Metrics */} - {latestResults && ( -
-

Detailed Metrics

-
- {/* Reading Metrics */} -
-

Reading

-
- {latestResults.reading_expected_pct !== null && ( -
- Expected - {formatPercentage(latestResults.reading_expected_pct)} -
- )} - {latestResults.reading_high_pct !== null && ( -
- Higher - {formatPercentage(latestResults.reading_high_pct)} -
- )} - {latestResults.reading_progress !== null && ( -
- Progress - {formatProgress(latestResults.reading_progress)} -
- )} - {latestResults.reading_avg_score !== null && ( -
- Avg Score - {latestResults.reading_avg_score.toFixed(1)} -
- )} -
-
- - {/* Writing Metrics */} -
-

Writing

-
- {latestResults.writing_expected_pct !== null && ( -
- Expected - {formatPercentage(latestResults.writing_expected_pct)} -
- )} - {latestResults.writing_high_pct !== null && ( -
- Higher - {formatPercentage(latestResults.writing_high_pct)} -
- )} - {latestResults.writing_progress !== null && ( -
- Progress - {formatProgress(latestResults.writing_progress)} -
- )} -
-
- - {/* Maths Metrics */} -
-

Maths

-
- {latestResults.maths_expected_pct !== null && ( -
- Expected - {formatPercentage(latestResults.maths_expected_pct)} -
- )} - {latestResults.maths_high_pct !== null && ( -
- Higher - {formatPercentage(latestResults.maths_high_pct)} -
- )} - {latestResults.maths_progress !== null && ( -
- Progress - {formatProgress(latestResults.maths_progress)} -
- )} - {latestResults.maths_avg_score !== null && ( -
- Avg Score - {latestResults.maths_avg_score.toFixed(1)} -
- )} -
-
-
-
- )} - - {/* Absence Data */} - {absenceData && ( -
-

Absence Data

-
- {absenceData.overall_absence_rate !== null && ( -
-
Overall Absence Rate
-
{formatPercentage(absenceData.overall_absence_rate)}
-
- )} - {absenceData.persistent_absence_rate !== null && ( -
-
Persistent Absence
-
{formatPercentage(absenceData.persistent_absence_rate)}
-
- )} -
-
- )} - - {/* All Years Data Table */} - {yearlyData.length > 0 && ( -
-

Historical Data

+ {/* Historical Data Table */} + {yearlyData.length > 1 && ( +
+

Year-by-Year Summary

- - + + @@ -342,209 +661,6 @@ export function SchoolDetailView({ )} - - {/* Ofsted Section */} - {ofsted && ( -
-

Ofsted Inspection

-
- - {ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'} - - {ofsted.inspection_date && ( - - Inspected: {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} - - )} -
-
- {[ - { 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 && ( -
-
{label}
-
- {OFSTED_LABELS[value]} -
-
- ))} -
- {ofsted.inspection_type && ( -

{ofsted.inspection_type}

- )} -
- )} - - {/* What Parents Think */} - {parentView && parentView.total_responses != null && parentView.total_responses > 0 && ( -
-

What Parents Think

-

- Based on {parentView.total_responses.toLocaleString()} parent responses to the Ofsted Parent View survey. -

-
- {[ - { 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 }) => ( -
- {label} -
-
-
- {pct}% -
- ))} -
-
- )} - - {/* Admissions */} - {admissions && ( -
-

Admissions ({admissions.year})

-
- {admissions.published_admission_number != null && ( -
-
Places available
-
{admissions.published_admission_number}
-
- )} - {admissions.total_applications != null && ( -
-
Applications received
-
{admissions.total_applications.toLocaleString()}
-
- )} - {admissions.first_preference_offers_pct != null && ( -
-
Got first choice
-
{admissions.first_preference_offers_pct}%
-
- )} - {admissions.oversubscribed != null && ( -
-
Oversubscribed
-
{admissions.oversubscribed ? 'Yes' : 'No'}
-
- )} -
-
- )} - - {/* Pupils & Inclusion (Census + SEN) */} - {(census || senDetail) && ( -
-

Pupils & Inclusion

-
- {census?.class_size_avg != null && ( -
-
Average class size
-
{census.class_size_avg.toFixed(1)}
-
- )} -
- {senDetail && ( - <> -

Primary SEN Needs (latest year)

-
- {[ - { 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 }) => ( -
-
{label}
-
{pct}%
-
- ))} -
- - )} -
- )} - - {/* Year 1 Phonics */} - {phonics && phonics.year1_phonics_pct != null && ( -
-

Year 1 Phonics ({phonics.year})

-
-
-
Reached expected standard
-
{formatPercentage(phonics.year1_phonics_pct)}
-
- {phonics.year2_phonics_pct != null && ( -
-
Year 2 (re-takers) standard
-
{formatPercentage(phonics.year2_phonics_pct)}
-
- )} -
-
- )} - - {/* Deprivation Context */} - {deprivation && deprivation.idaci_decile != null && ( -
-

Deprivation Context

-
-
-
Area deprivation decile
-
{deprivation.idaci_decile} / 10
-
- 1 = most deprived, 10 = least deprived -
-
- {deprivation.idaci_score != null && ( -
-
IDACI score
-
{deprivation.idaci_score.toFixed(3)}
-
- )} -
-
- )} - - {/* Finances */} - {finance && finance.per_pupil_spend != null && ( -
-

Finances ({finance.year})

-
- {finance.per_pupil_spend != null && ( -
-
Spend per pupil
-
£{Math.round(finance.per_pupil_spend).toLocaleString()}
-
- )} - {finance.teacher_cost_pct != null && ( -
-
Teacher costs
-
{finance.teacher_cost_pct.toFixed(1)}% of budget
-
- )} - {finance.staff_cost_pct != null && ( -
-
All staff costs
-
{finance.staff_cost_pct.toFixed(1)}% of budget
-
- )} -
-
- )} ); }
YearRWM ExpectedRWM HigherReading, Writing & Maths (expected %)Exceeding expected (%) Reading Progress Writing Progress Maths Progress