Compare commits
71 Commits
d82e36e7b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b025b98bd | ||
|
|
4c3c3c882d | ||
|
|
d591d8e66b | ||
|
|
4db36b9099 | ||
|
|
cacbeeb068 | ||
|
|
d5f6366c28 | ||
|
|
2b757e556d | ||
|
|
fbd1de9220 | ||
|
|
fba8e74b72 | ||
|
|
6d4962639c | ||
|
|
fc011c6547 | ||
|
|
752abd69a5 | ||
|
|
570c2b689e | ||
|
|
17617137ea | ||
|
|
9a1572ea20 | ||
| f48faa1803 | |||
| 6e5249aa1e | |||
| 695a571c1f | |||
| bd4e71dd30 | |||
| cd6a5d092c | |||
| 5aed055331 | |||
| d6a45b8e12 | |||
| daf24e4739 | |||
| 0c5bef34cf | |||
| 5615458223 | |||
| 9c9528b51b | |||
| 1009d7c976 | |||
| 790b12a7f3 | |||
| 8f4c052294 | |||
| b7bff7bf6b | |||
| 748891ab31 | |||
| 17b8873f0f | |||
| 15c0055687 | |||
| 6315f366c8 | |||
| 784febc162 | |||
| e2c700fcfc | |||
| 77a0f5b674 | |||
| 63dfa22255 | |||
| 1d22877aec | |||
| e8175561d5 | |||
| f3a8ebdb4b | |||
| f0c76a1724 | |||
| 3e787b395f | |||
| 3d1c4c61c9 | |||
| 250d1f7c77 | |||
| 5eff9af69c | |||
| b0990e30ee | |||
| 1629a8f994 | |||
| 55749bdfaf | |||
| cd1c649d0f | |||
| 7724fe3503 | |||
| 1d56eebe87 | |||
| 10720400fd | |||
| 05cb22f1a5 | |||
| 26aa3c2d70 | |||
| e56a63c59c | |||
| 221923857d | |||
| 62284e7a94 | |||
| 668e234eb2 | |||
| 4b02ab3d8a | |||
| 5d8b319451 | |||
| 77f75fb6e5 | |||
| b41e6c250e | |||
| 6e720feca4 | |||
| ae9fd26eba | |||
| 33b395d2bd | |||
| 8e8d1bd8c5 | |||
| c7357336e3 | |||
| b8ecc5c58b | |||
| f4f0257447 | |||
| ca351e9d73 |
@@ -12,8 +12,6 @@ env:
|
||||
REGISTRY: privaterepo.sitaru.org
|
||||
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
||||
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
|
||||
INTEGRATOR_IMAGE_NAME: ${{ gitea.repository }}-integrator
|
||||
KESTRA_INIT_IMAGE_NAME: ${{ gitea.repository }}-kestra-init
|
||||
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
|
||||
|
||||
jobs:
|
||||
@@ -112,94 +110,6 @@ jobs:
|
||||
# cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache
|
||||
# cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache,mode=max
|
||||
|
||||
build-integrator:
|
||||
name: Build Integrator
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["10.0.1.224:6000"]
|
||||
[registry."10.0.1.224:6000"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Integrator Docker image
|
||||
id: meta-integrator
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.INTEGRATOR_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix=integrator-
|
||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Integrator Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./integrator
|
||||
file: ./integrator/Dockerfile
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-integrator.outputs.tags }}
|
||||
labels: ${{ steps.meta-integrator.outputs.labels }}
|
||||
|
||||
build-kestra-init:
|
||||
name: Build Kestra Init
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["10.0.1.224:6000"]
|
||||
[registry."10.0.1.224:6000"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Kestra Init Docker image
|
||||
id: meta-kestra-init
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.KESTRA_INIT_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix=kestra-init-
|
||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Kestra Init Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./integrator
|
||||
file: ./integrator/Dockerfile.init
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-kestra-init.outputs.tags }}
|
||||
labels: ${{ steps.meta-kestra-init.outputs.labels }}
|
||||
|
||||
build-pipeline:
|
||||
name: Build Pipeline (Meltano + dbt + Airflow)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -249,7 +159,7 @@ jobs:
|
||||
trigger-deployment:
|
||||
name: Trigger Portainer Update
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-backend, build-frontend, build-integrator, build-kestra-init, build-pipeline]
|
||||
needs: [build-backend, build-frontend, build-pipeline]
|
||||
if: gitea.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Trigger Portainer stack update
|
||||
|
||||
@@ -22,13 +22,10 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY data/ ./data/
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 80
|
||||
|
||||
# Run the application (using module import)
|
||||
CMD ["python", "-m", "uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "80"]
|
||||
|
||||
|
||||
367
backend/app.py
367
backend/app.py
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
SchoolCompare.co.uk API
|
||||
Serves primary school (KS2) performance data for comparing schools.
|
||||
Serves primary and secondary school performance data for comparing schools.
|
||||
Uses real data from UK Government Compare School Performance downloads.
|
||||
"""
|
||||
|
||||
@@ -26,13 +26,91 @@ from .data_loader import (
|
||||
load_school_data,
|
||||
geocode_single_postcode,
|
||||
get_supplementary_data,
|
||||
search_schools_typesense,
|
||||
)
|
||||
from .data_loader import get_data_info as get_db_info
|
||||
from .database import check_and_migrate_if_needed
|
||||
from .migration import run_full_migration
|
||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||
from .utils import clean_for_json
|
||||
|
||||
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
||||
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
|
||||
|
||||
# Maps user-facing phase filter values to the GIAS PhaseOfEducation values they include.
|
||||
# All-through schools appear in both primary and secondary results.
|
||||
PHASE_GROUPS: dict[str, set[str]] = {
|
||||
"primary": {"primary", "middle deemed primary", "all-through"},
|
||||
"secondary": {"secondary", "middle deemed secondary", "all-through", "16 plus"},
|
||||
"all-through": {"all-through"},
|
||||
}
|
||||
|
||||
BASE_URL = "https://schoolcompare.co.uk"
|
||||
MAX_SLUG_LENGTH = 60
|
||||
|
||||
# In-memory sitemap cache
|
||||
_sitemap_xml: str | None = None
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
text = text.lower()
|
||||
text = re.sub(r"[^\w\s-]", "", text)
|
||||
text = re.sub(r"\s+", "-", text)
|
||||
text = re.sub(r"-+", "-", text)
|
||||
return text.strip("-")
|
||||
|
||||
|
||||
def _school_url(urn: int, school_name: str) -> str:
|
||||
slug = _slugify(school_name)
|
||||
if len(slug) > MAX_SLUG_LENGTH:
|
||||
slug = slug[:MAX_SLUG_LENGTH].rstrip("-")
|
||||
return f"/school/{urn}-{slug}"
|
||||
|
||||
|
||||
def build_sitemap() -> str:
|
||||
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
|
||||
df = load_school_data()
|
||||
|
||||
static_urls = [
|
||||
(BASE_URL + "/", "daily", "1.0"),
|
||||
(BASE_URL + "/rankings", "weekly", "0.8"),
|
||||
(BASE_URL + "/compare", "weekly", "0.8"),
|
||||
]
|
||||
|
||||
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
||||
|
||||
for url, freq, priority in static_urls:
|
||||
lines.append(
|
||||
f" <url><loc>{url}</loc>"
|
||||
f"<changefreq>{freq}</changefreq>"
|
||||
f"<priority>{priority}</priority></url>"
|
||||
)
|
||||
|
||||
if not df.empty and "urn" in df.columns and "school_name" in df.columns:
|
||||
seen = set()
|
||||
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
|
||||
urn = int(row["urn"])
|
||||
name = str(row["school_name"])
|
||||
if urn in seen:
|
||||
continue
|
||||
seen.add(urn)
|
||||
path = _school_url(urn, name)
|
||||
lines.append(
|
||||
f" <url><loc>{BASE_URL}{path}</loc>"
|
||||
f"<changefreq>monthly</changefreq>"
|
||||
f"<priority>0.6</priority></url>"
|
||||
)
|
||||
|
||||
lines.append("</urlset>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def clean_filter_values(series: pd.Series) -> list[str]:
|
||||
"""Return sorted unique values from a Series, excluding NaN and junk labels."""
|
||||
return sorted(
|
||||
v for v in series.dropna().unique().tolist()
|
||||
if v not in EXCLUDED_FILTER_VALUES
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY MIDDLEWARE & HELPERS
|
||||
@@ -138,26 +216,28 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan - startup and shutdown events."""
|
||||
# Startup: check schema version and migrate if needed
|
||||
print("Starting up: Checking database schema...")
|
||||
check_and_migrate_if_needed()
|
||||
|
||||
print("Loading school data from database...")
|
||||
global _sitemap_xml
|
||||
print("Loading school data from marts...")
|
||||
df = load_school_data()
|
||||
if df.empty:
|
||||
print("Warning: No data in database. Check CSV files in data/ folder.")
|
||||
print("Warning: No data in marts. Run the annual EES pipeline to populate KS2 data.")
|
||||
else:
|
||||
print(f"Data loaded successfully: {len(df)} records.")
|
||||
try:
|
||||
_sitemap_xml = build_sitemap()
|
||||
n = _sitemap_xml.count("<url>")
|
||||
print(f"Sitemap built: {n} URLs.")
|
||||
except Exception as e:
|
||||
print(f"Warning: sitemap build failed on startup: {e}")
|
||||
|
||||
yield # Application runs here
|
||||
yield
|
||||
|
||||
# Shutdown: cleanup if needed
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SchoolCompare API",
|
||||
description="API for comparing primary school (KS2) performance data - schoolcompare.co.uk",
|
||||
description="API for comparing primary and secondary school performance data - schoolcompare.co.uk",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
# Disable docs in production for security
|
||||
@@ -219,21 +299,26 @@ async def get_schools(
|
||||
None, description="Filter by local authority", max_length=100
|
||||
),
|
||||
school_type: Optional[str] = Query(None, description="Filter by school type", max_length=100),
|
||||
phase: Optional[str] = Query(None, description="Filter by phase: primary, secondary, all-through", max_length=50),
|
||||
postcode: Optional[str] = Query(None, description="Search near postcode", max_length=10),
|
||||
radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"),
|
||||
radius: float = Query(5.0, ge=0.1, le=5, description="Search radius in miles"),
|
||||
page: int = Query(1, ge=1, le=1000, description="Page number"),
|
||||
page_size: int = Query(None, ge=1, le=100, description="Results per page"),
|
||||
page_size: int = Query(25, ge=1, le=500, description="Results per page"),
|
||||
gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50),
|
||||
admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100),
|
||||
has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3),
|
||||
):
|
||||
"""
|
||||
Get list of unique primary schools with pagination.
|
||||
Get list of schools with pagination.
|
||||
|
||||
Returns paginated results with total count for efficient loading.
|
||||
Supports location-based search using postcode.
|
||||
Supports location-based search using postcode and phase filtering.
|
||||
"""
|
||||
# Sanitize inputs
|
||||
search = sanitize_search_input(search)
|
||||
local_authority = sanitize_search_input(local_authority)
|
||||
school_type = sanitize_search_input(school_type)
|
||||
phase = sanitize_search_input(phase)
|
||||
postcode = validate_postcode(postcode)
|
||||
|
||||
df = load_school_data()
|
||||
@@ -245,6 +330,11 @@ async def get_schools(
|
||||
if page_size is None:
|
||||
page_size = settings.default_page_size
|
||||
|
||||
# Schools with no performance data (special schools, PRUs, newly opened, etc.)
|
||||
# have NULL year from the LEFT JOIN — keep them but skip the groupby/trend logic.
|
||||
df_no_perf = df[df["year"].isna()].drop_duplicates(subset=["urn"])
|
||||
df = df[df["year"].notna()]
|
||||
|
||||
# Get unique schools (latest year data for each)
|
||||
latest_year = df.groupby("urn")["year"].max().reset_index()
|
||||
df_latest = df.merge(latest_year, on=["urn", "year"])
|
||||
@@ -257,26 +347,59 @@ async def get_schools(
|
||||
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
||||
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
||||
)
|
||||
if "attainment_8_score" in df_prev.columns:
|
||||
prev_rwm = prev_rwm.merge(
|
||||
df_prev[["urn", "attainment_8_score"]].rename(
|
||||
columns={"attainment_8_score": "prev_attainment_8_score"}
|
||||
),
|
||||
on="urn", how="outer"
|
||||
)
|
||||
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
||||
|
||||
# Merge back schools with no performance data
|
||||
df_latest = pd.concat([df_latest, df_no_perf], ignore_index=True)
|
||||
|
||||
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
|
||||
# in the correct phase(s) rather than being invisible to both filters.
|
||||
if phase:
|
||||
phase_lower = phase.lower().replace("_", "-")
|
||||
allowed = PHASE_GROUPS.get(phase_lower)
|
||||
if allowed:
|
||||
df_latest = df_latest[df_latest["phase"].str.lower().isin(allowed)]
|
||||
|
||||
# Secondary-specific filters (after phase filter)
|
||||
if gender:
|
||||
df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
|
||||
if admissions_policy:
|
||||
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
|
||||
if has_sixth_form == "yes":
|
||||
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
|
||||
elif has_sixth_form == "no":
|
||||
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
|
||||
|
||||
# Include key result metrics for display on cards
|
||||
location_cols = ["latitude", "longitude"]
|
||||
result_cols = [
|
||||
"phase",
|
||||
"year",
|
||||
"rwm_expected_pct",
|
||||
"rwm_high_pct",
|
||||
"prev_rwm_expected_pct",
|
||||
"prev_attainment_8_score",
|
||||
"reading_expected_pct",
|
||||
"writing_expected_pct",
|
||||
"maths_expected_pct",
|
||||
"total_pupils",
|
||||
"attainment_8_score",
|
||||
"english_maths_standard_pass_pct",
|
||||
]
|
||||
available_cols = [
|
||||
c
|
||||
for c in SCHOOL_COLUMNS + location_cols + result_cols
|
||||
if c in df_latest.columns
|
||||
]
|
||||
schools_df = df_latest[available_cols].drop_duplicates(subset=["urn"])
|
||||
# fact_performance guarantees one row per (urn, year); df_latest has one row per urn.
|
||||
schools_df = df_latest[available_cols]
|
||||
|
||||
# Location-based search (uses pre-geocoded data from database)
|
||||
search_coords = None
|
||||
@@ -321,14 +444,18 @@ async def get_schools(
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
ts_urns = search_schools_typesense(search)
|
||||
if ts_urns:
|
||||
urn_order = {urn: i for i, urn in enumerate(ts_urns)}
|
||||
schools_df = schools_df[schools_df["urn"].isin(set(ts_urns))].copy()
|
||||
schools_df["_ts_rank"] = schools_df["urn"].map(urn_order)
|
||||
schools_df = schools_df.sort_values("_ts_rank").drop(columns=["_ts_rank"])
|
||||
else:
|
||||
# Fallback: Typesense unavailable, use substring match
|
||||
search_lower = search.lower()
|
||||
mask = (
|
||||
schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
|
||||
)
|
||||
mask = schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
|
||||
if "address" in schools_df.columns:
|
||||
mask = mask | schools_df["address"].str.lower().str.contains(
|
||||
search_lower, na=False
|
||||
)
|
||||
mask = mask | schools_df["address"].str.lower().str.contains(search_lower, na=False)
|
||||
schools_df = schools_df[mask]
|
||||
|
||||
if local_authority:
|
||||
@@ -341,6 +468,18 @@ async def get_schools(
|
||||
schools_df["school_type"].str.lower() == school_type.lower()
|
||||
]
|
||||
|
||||
# Compute result-scoped filter values (before pagination).
|
||||
# Gender and admissions are secondary-only filters — scope them to schools
|
||||
# with KS4 data so they don't appear for purely primary result sets.
|
||||
_sec_mask = schools_df["attainment_8_score"].notna() if "attainment_8_score" in schools_df.columns else pd.Series(False, index=schools_df.index)
|
||||
result_filters = {
|
||||
"local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [],
|
||||
"school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [],
|
||||
"phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [],
|
||||
"genders": clean_filter_values(schools_df.loc[_sec_mask, "gender"]) if "gender" in schools_df.columns and _sec_mask.any() else [],
|
||||
"admissions_policies": clean_filter_values(schools_df.loc[_sec_mask, "admissions_policy"]) if "admissions_policy" in schools_df.columns and _sec_mask.any() else [],
|
||||
}
|
||||
|
||||
# Pagination
|
||||
total = len(schools_df)
|
||||
start_idx = (page - 1) * page_size
|
||||
@@ -353,6 +492,7 @@ async def get_schools(
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
||||
"result_filters": result_filters,
|
||||
"location_info": {
|
||||
"postcode": postcode,
|
||||
"radius": radius * 1.60934, # Convert miles to km for frontend display
|
||||
@@ -366,7 +506,7 @@ async def get_schools(
|
||||
@app.get("/api/schools/{urn}")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_school_details(request: Request, urn: int):
|
||||
"""Get detailed KS2 data for a specific primary school across all years."""
|
||||
"""Get detailed performance data for a specific school across all years."""
|
||||
# Validate URN range (UK school URNs are 6 digits)
|
||||
if not (100000 <= urn <= 999999):
|
||||
raise HTTPException(status_code=400, detail="Invalid URN format")
|
||||
@@ -408,7 +548,7 @@ async def get_school_details(request: Request, urn: int):
|
||||
"age_range": latest.get("age_range", ""),
|
||||
"latitude": latest.get("latitude"),
|
||||
"longitude": latest.get("longitude"),
|
||||
"phase": "Primary",
|
||||
"phase": latest.get("phase"),
|
||||
# GIAS fields
|
||||
"website": latest.get("website"),
|
||||
"headteacher_name": latest.get("headteacher_name"),
|
||||
@@ -435,7 +575,7 @@ async def compare_schools(
|
||||
request: Request,
|
||||
urns: str = Query(..., description="Comma-separated URNs", max_length=100)
|
||||
):
|
||||
"""Compare multiple primary schools side by side."""
|
||||
"""Compare multiple schools side by side."""
|
||||
df = load_school_data()
|
||||
|
||||
if df.empty:
|
||||
@@ -468,7 +608,11 @@ async def compare_schools(
|
||||
"urn": urn,
|
||||
"school_name": latest.get("school_name", ""),
|
||||
"local_authority": latest.get("local_authority", ""),
|
||||
"school_type": latest.get("school_type", ""),
|
||||
"address": latest.get("address", ""),
|
||||
"phase": latest.get("phase", ""),
|
||||
"attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None,
|
||||
"rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None,
|
||||
},
|
||||
"yearly_data": clean_for_json(school_data),
|
||||
}
|
||||
@@ -489,10 +633,85 @@ async def get_filter_options(request: Request):
|
||||
"years": [],
|
||||
}
|
||||
|
||||
# Phases: return values from data, ordered sensibly
|
||||
phases = clean_filter_values(df["phase"]) if "phase" in df.columns else []
|
||||
|
||||
secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
|
||||
genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else []
|
||||
admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) if "admissions_policy" in secondary_df.columns else []
|
||||
|
||||
return {
|
||||
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
|
||||
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
|
||||
"local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [],
|
||||
"school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [],
|
||||
"years": sorted(df["year"].dropna().unique().tolist()),
|
||||
"phases": phases,
|
||||
"genders": genders,
|
||||
"admissions_policies": admissions_policies,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/la-averages")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_la_averages(request: Request):
|
||||
"""Get per-LA average Attainment 8 score for secondary schools in the latest year."""
|
||||
df = load_school_data()
|
||||
if df.empty:
|
||||
return {"year": 0, "secondary": {"attainment_8_by_la": {}}}
|
||||
latest_year = int(df["year"].max())
|
||||
sec_df = df[(df["year"] == latest_year) & df["attainment_8_score"].notna()]
|
||||
la_avg = sec_df.groupby("local_authority")["attainment_8_score"].mean().round(1).to_dict()
|
||||
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
|
||||
|
||||
|
||||
@app.get("/api/national-averages")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_national_averages(request: Request):
|
||||
"""
|
||||
Compute national average for each metric from the latest data year.
|
||||
Returns separate averages for primary (KS2) and secondary (KS4) schools.
|
||||
Values are derived from the loaded DataFrame so they automatically
|
||||
stay current when new data is loaded.
|
||||
"""
|
||||
df = load_school_data()
|
||||
if df.empty:
|
||||
return {"primary": {}, "secondary": {}}
|
||||
|
||||
latest_year = int(df["year"].max())
|
||||
df_latest = df[df["year"] == latest_year]
|
||||
|
||||
ks2_metrics = [
|
||||
"rwm_expected_pct", "rwm_high_pct",
|
||||
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
||||
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||
"reading_progress", "writing_progress", "maths_progress",
|
||||
"overall_absence_pct", "persistent_absence_pct",
|
||||
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct",
|
||||
]
|
||||
ks4_metrics = [
|
||||
"attainment_8_score", "progress_8_score",
|
||||
"english_maths_standard_pass_pct", "english_maths_strong_pass_pct",
|
||||
"ebacc_entry_pct", "ebacc_standard_pass_pct", "ebacc_strong_pass_pct",
|
||||
"ebacc_avg_score", "gcse_grade_91_pct",
|
||||
]
|
||||
|
||||
def _means(sub_df, metric_list):
|
||||
out = {}
|
||||
for col in metric_list:
|
||||
if col in sub_df.columns:
|
||||
val = sub_df[col].dropna()
|
||||
if len(val) > 0:
|
||||
out[col] = round(float(val.mean()), 2)
|
||||
return out
|
||||
|
||||
# Primary: schools where KS2 data is non-null
|
||||
primary_df = df_latest[df_latest["rwm_expected_pct"].notna()]
|
||||
# Secondary: schools where KS4 data is non-null
|
||||
secondary_df = df_latest[df_latest["attainment_8_score"].notna()]
|
||||
|
||||
return {
|
||||
"year": latest_year,
|
||||
"primary": _means(primary_df, ks2_metrics),
|
||||
"secondary": _means(secondary_df, ks4_metrics),
|
||||
}
|
||||
|
||||
|
||||
@@ -500,7 +719,7 @@ async def get_filter_options(request: Request):
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_available_metrics(request: Request):
|
||||
"""
|
||||
Get list of available KS2 performance metrics for primary schools.
|
||||
Get list of available performance metrics for schools.
|
||||
|
||||
This is the single source of truth for metric definitions.
|
||||
Frontend should consume this to avoid duplication.
|
||||
@@ -519,7 +738,7 @@ async def get_available_metrics(request: Request):
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_rankings(
|
||||
request: Request,
|
||||
metric: str = Query("rwm_expected_pct", description="KS2 metric to rank by", max_length=50),
|
||||
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
|
||||
year: Optional[int] = Query(
|
||||
None, description="Specific year (defaults to most recent)", ge=2000, le=2100
|
||||
),
|
||||
@@ -527,8 +746,11 @@ async def get_rankings(
|
||||
local_authority: Optional[str] = Query(
|
||||
None, description="Filter by local authority", max_length=100
|
||||
),
|
||||
phase: Optional[str] = Query(
|
||||
None, description="Filter by phase: primary or secondary", max_length=20
|
||||
),
|
||||
):
|
||||
"""Get primary school rankings by a specific KS2 metric."""
|
||||
"""Get school rankings by a specific metric."""
|
||||
# Sanitize local authority input
|
||||
local_authority = sanitize_search_input(local_authority)
|
||||
|
||||
@@ -556,6 +778,12 @@ async def get_rankings(
|
||||
if local_authority:
|
||||
df = df[df["local_authority"].str.lower() == local_authority.lower()]
|
||||
|
||||
# Filter by phase
|
||||
if phase == "primary" and "rwm_expected_pct" in df.columns:
|
||||
df = df[df["rwm_expected_pct"].notna()]
|
||||
elif phase == "secondary" and "attainment_8_score" in df.columns:
|
||||
df = df[df["attainment_8_score"].notna()]
|
||||
|
||||
# Sort and rank (exclude rows with no data for this metric)
|
||||
df = df.dropna(subset=[metric])
|
||||
total = len(df)
|
||||
@@ -585,7 +813,7 @@ async def get_data_info(request: Request):
|
||||
if db_info["total_schools"] == 0:
|
||||
return {
|
||||
"status": "no_data",
|
||||
"message": "No data in database. Run the migration script: python scripts/migrate_csv_to_db.py",
|
||||
"message": "No data in marts. Run the annual EES pipeline to load KS2 data.",
|
||||
"data_source": "PostgreSQL",
|
||||
}
|
||||
|
||||
@@ -599,10 +827,10 @@ async def get_data_info(request: Request):
|
||||
"data_source": "PostgreSQL",
|
||||
}
|
||||
|
||||
years = [int(y) for y in sorted(df["year"].unique())]
|
||||
years = [int(y) for y in sorted(df["year"].dropna().unique())]
|
||||
schools_per_year = {
|
||||
str(int(k)): int(v)
|
||||
for k, v in df.groupby("year")["urn"].nunique().to_dict().items()
|
||||
for k, v in df.dropna(subset=["year"]).groupby("year")["urn"].nunique().to_dict().items()
|
||||
}
|
||||
la_counts = {
|
||||
str(k): int(v)
|
||||
@@ -635,56 +863,6 @@ async def reload_data(
|
||||
return {"status": "reloaded"}
|
||||
|
||||
|
||||
_reimport_status: dict = {"running": False, "done": False, "error": None}
|
||||
|
||||
|
||||
@app.post("/api/admin/reimport-ks2")
|
||||
@limiter.limit("2/minute")
|
||||
async def reimport_ks2(
|
||||
request: Request,
|
||||
geocode: bool = True,
|
||||
_: bool = Depends(verify_admin_api_key)
|
||||
):
|
||||
"""
|
||||
Start a full KS2 CSV migration in the background and return immediately.
|
||||
Poll GET /api/admin/reimport-ks2/status to check progress.
|
||||
Pass ?geocode=false to skip postcode → lat/lng resolution.
|
||||
Requires X-API-Key header with valid admin API key.
|
||||
"""
|
||||
global _reimport_status
|
||||
if _reimport_status["running"]:
|
||||
return {"status": "already_running"}
|
||||
|
||||
_reimport_status = {"running": True, "done": False, "error": None}
|
||||
|
||||
def _run():
|
||||
global _reimport_status
|
||||
try:
|
||||
success = run_full_migration(geocode=geocode)
|
||||
if not success:
|
||||
_reimport_status = {"running": False, "done": False, "error": "No CSV data found"}
|
||||
return
|
||||
clear_cache()
|
||||
load_school_data()
|
||||
_reimport_status = {"running": False, "done": True, "error": None}
|
||||
except Exception as exc:
|
||||
_reimport_status = {"running": False, "done": False, "error": str(exc)}
|
||||
|
||||
import threading
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return {"status": "started"}
|
||||
|
||||
|
||||
@app.get("/api/admin/reimport-ks2/status")
|
||||
async def reimport_ks2_status(
|
||||
request: Request,
|
||||
_: bool = Depends(verify_admin_api_key)
|
||||
):
|
||||
"""Poll this endpoint to check reimport progress."""
|
||||
s = _reimport_status
|
||||
if s["error"]:
|
||||
raise HTTPException(status_code=500, detail=s["error"])
|
||||
return {"running": s["running"], "done": s["done"]}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -707,7 +885,26 @@ async def robots_txt():
|
||||
@app.get("/sitemap.xml")
|
||||
async def sitemap_xml():
|
||||
"""Serve sitemap.xml for search engine indexing."""
|
||||
return FileResponse(settings.frontend_dir / "sitemap.xml", media_type="application/xml")
|
||||
global _sitemap_xml
|
||||
if _sitemap_xml is None:
|
||||
try:
|
||||
_sitemap_xml = build_sitemap()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
|
||||
return Response(content=_sitemap_xml, media_type="application/xml")
|
||||
|
||||
|
||||
@app.post("/api/admin/regenerate-sitemap")
|
||||
@limiter.limit("10/minute")
|
||||
async def regenerate_sitemap(
|
||||
request: Request,
|
||||
_: bool = Depends(verify_admin_api_key),
|
||||
):
|
||||
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
|
||||
global _sitemap_xml
|
||||
_sitemap_xml = build_sitemap()
|
||||
n = _sitemap_xml.count("<url>")
|
||||
return {"status": "ok", "urls": n}
|
||||
|
||||
|
||||
# Mount static files directly (must be after all routes to avoid catching API calls)
|
||||
|
||||
@@ -38,6 +38,10 @@ class Settings(BaseSettings):
|
||||
rate_limit_burst: int = 10 # Allow burst of requests
|
||||
max_request_size: int = 1024 * 1024 # 1MB max request size
|
||||
|
||||
# Typesense
|
||||
typesense_url: str = "http://localhost:8108"
|
||||
typesense_api_key: str = ""
|
||||
|
||||
# Analytics
|
||||
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
|
||||
|
||||
|
||||
@@ -1,545 +1,252 @@
|
||||
"""
|
||||
Data loading module that queries from PostgreSQL database.
|
||||
Provides efficient queries with caching and lazy loading.
|
||||
|
||||
Note: School geocoding is handled by a separate cron job (scripts/geocode_schools.py).
|
||||
Only user search postcodes are geocoded on-demand via geocode_single_postcode().
|
||||
Data loading module — reads from marts.* tables built by dbt.
|
||||
Provides efficient queries with caching.
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from functools import lru_cache
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
import requests
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
from sqlalchemy.orm import joinedload, Session
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .database import SessionLocal, get_db_session
|
||||
from .database import SessionLocal, engine
|
||||
from .models import (
|
||||
School, SchoolResult,
|
||||
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||
SchoolAdmissions, SenDetail, Phonics, SchoolDeprivation, SchoolFinance,
|
||||
DimSchool, DimLocation, KS2Performance,
|
||||
FactOfstedInspection, FactParentView, FactAdmissions,
|
||||
FactDeprivation, FactFinance,
|
||||
)
|
||||
from .schemas import SCHOOL_TYPE_MAP
|
||||
|
||||
# Cache for user search postcode geocoding (not for school data)
|
||||
_postcode_cache: Dict[str, Tuple[float, float]] = {}
|
||||
_typesense_client = None
|
||||
|
||||
|
||||
def _get_typesense_client():
|
||||
global _typesense_client
|
||||
if _typesense_client is not None:
|
||||
return _typesense_client
|
||||
url = settings.typesense_url
|
||||
key = settings.typesense_api_key
|
||||
if not url or not key:
|
||||
return None
|
||||
try:
|
||||
import typesense
|
||||
host = url.split("//")[-1]
|
||||
host_part, _, port_str = host.partition(":")
|
||||
port = int(port_str) if port_str else 8108
|
||||
_typesense_client = typesense.Client({
|
||||
"nodes": [{"host": host_part, "port": str(port), "protocol": "http"}],
|
||||
"api_key": key,
|
||||
"connection_timeout_seconds": 2,
|
||||
})
|
||||
return _typesense_client
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def search_schools_typesense(query: str, limit: int = 250) -> List[int]:
|
||||
"""Search Typesense. Returns URNs in relevance order, or [] if unavailable."""
|
||||
client = _get_typesense_client()
|
||||
if client is None:
|
||||
return []
|
||||
try:
|
||||
result = client.collections["schools"].documents.search({
|
||||
"q": query,
|
||||
"query_by": "school_name,local_authority,postcode",
|
||||
"per_page": min(limit, 250),
|
||||
"typo_tokens_threshold": 1,
|
||||
})
|
||||
return [int(h["document"]["urn"]) for h in result.get("hits", [])]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def normalize_school_type(school_type: Optional[str]) -> Optional[str]:
|
||||
"""Convert cryptic school type codes to user-friendly names."""
|
||||
if not school_type:
|
||||
return None
|
||||
# Check if it's a code that needs mapping
|
||||
code = school_type.strip().upper()
|
||||
if code in SCHOOL_TYPE_MAP:
|
||||
return SCHOOL_TYPE_MAP[code]
|
||||
# Return original if already a friendly name or unknown code
|
||||
return school_type
|
||||
|
||||
|
||||
def get_school_type_codes_for_filter(school_type: str) -> List[str]:
|
||||
"""Get all database codes that map to a given friendly name."""
|
||||
if not school_type:
|
||||
return []
|
||||
school_type_lower = school_type.lower()
|
||||
# Collect all codes that map to this friendly name
|
||||
codes = []
|
||||
for code, friendly_name in SCHOOL_TYPE_MAP.items():
|
||||
if friendly_name.lower() == school_type_lower:
|
||||
codes.append(code.lower())
|
||||
# Also include the school_type itself (case-insensitive) in case it's stored as-is
|
||||
codes.append(school_type_lower)
|
||||
return codes
|
||||
|
||||
|
||||
def geocode_single_postcode(postcode: str) -> Optional[Tuple[float, float]]:
|
||||
"""Geocode a single postcode using postcodes.io API."""
|
||||
if not postcode:
|
||||
return None
|
||||
|
||||
postcode = postcode.strip().upper()
|
||||
|
||||
# Check cache first
|
||||
if postcode in _postcode_cache:
|
||||
return _postcode_cache[postcode]
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'https://api.postcodes.io/postcodes/{postcode}',
|
||||
timeout=10
|
||||
f"https://api.postcodes.io/postcodes/{postcode}",
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('result'):
|
||||
lat = data['result'].get('latitude')
|
||||
lon = data['result'].get('longitude')
|
||||
if data.get("result"):
|
||||
lat = data["result"].get("latitude")
|
||||
lon = data["result"].get("longitude")
|
||||
if lat and lon:
|
||||
_postcode_cache[postcode] = (lat, lon)
|
||||
return (lat, lon)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""
|
||||
Calculate the great circle distance between two points on Earth (in miles).
|
||||
"""
|
||||
"""Calculate great-circle distance between two points (miles)."""
|
||||
from math import radians, cos, sin, asin, sqrt
|
||||
|
||||
# Convert to radians
|
||||
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
||||
|
||||
# Haversine formula
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
||||
c = 2 * asin(sqrt(a))
|
||||
|
||||
# Earth's radius in miles
|
||||
r = 3956
|
||||
|
||||
return c * r
|
||||
return 2 * asin(sqrt(a)) * 3956
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE QUERY FUNCTIONS
|
||||
# MAIN DATA LOAD — joins dim_school + dim_location + fact_performance
|
||||
# fact_performance is a merged KS2+KS4 table (one row per URN per year).
|
||||
# All-through schools have both KS2 and KS4 columns populated in the same row.
|
||||
# =============================================================================
|
||||
|
||||
def get_db():
|
||||
"""Get a database session."""
|
||||
return SessionLocal()
|
||||
_MAIN_QUERY = text("""
|
||||
SELECT
|
||||
s.urn,
|
||||
s.school_name,
|
||||
s.phase,
|
||||
s.school_type,
|
||||
s.academy_trust_name AS trust_name,
|
||||
s.academy_trust_uid AS trust_uid,
|
||||
s.religious_character AS religious_denomination,
|
||||
s.gender,
|
||||
s.age_range,
|
||||
s.admissions_policy,
|
||||
s.capacity,
|
||||
s.headteacher_name,
|
||||
s.website,
|
||||
s.ofsted_grade,
|
||||
s.ofsted_date,
|
||||
s.ofsted_framework,
|
||||
l.local_authority_name AS local_authority,
|
||||
l.local_authority_code,
|
||||
l.address_line1 AS address1,
|
||||
l.address_line2 AS address2,
|
||||
l.town,
|
||||
l.postcode,
|
||||
l.latitude,
|
||||
l.longitude,
|
||||
p.year,
|
||||
p.source_urn,
|
||||
p.total_pupils,
|
||||
p.eligible_pupils,
|
||||
-- KS2 columns (NULL for pure secondary schools)
|
||||
p.rwm_expected_pct,
|
||||
p.rwm_high_pct,
|
||||
p.reading_expected_pct,
|
||||
p.reading_high_pct,
|
||||
p.reading_avg_score,
|
||||
p.reading_progress,
|
||||
p.writing_expected_pct,
|
||||
p.writing_high_pct,
|
||||
p.writing_progress,
|
||||
p.maths_expected_pct,
|
||||
p.maths_high_pct,
|
||||
p.maths_avg_score,
|
||||
p.maths_progress,
|
||||
p.gps_expected_pct,
|
||||
p.gps_high_pct,
|
||||
p.gps_avg_score,
|
||||
p.science_expected_pct,
|
||||
p.reading_absence_pct,
|
||||
p.writing_absence_pct,
|
||||
p.maths_absence_pct,
|
||||
p.gps_absence_pct,
|
||||
p.science_absence_pct,
|
||||
p.rwm_expected_boys_pct,
|
||||
p.rwm_high_boys_pct,
|
||||
p.rwm_expected_girls_pct,
|
||||
p.rwm_high_girls_pct,
|
||||
p.rwm_expected_disadvantaged_pct,
|
||||
p.rwm_expected_non_disadvantaged_pct,
|
||||
p.disadvantaged_gap,
|
||||
p.disadvantaged_pct,
|
||||
p.eal_pct,
|
||||
p.stability_pct,
|
||||
-- KS4 columns (NULL for pure primary schools)
|
||||
p.attainment_8_score,
|
||||
p.progress_8_score,
|
||||
p.progress_8_lower_ci,
|
||||
p.progress_8_upper_ci,
|
||||
p.progress_8_english,
|
||||
p.progress_8_maths,
|
||||
p.progress_8_ebacc,
|
||||
p.progress_8_open,
|
||||
p.english_maths_strong_pass_pct,
|
||||
p.english_maths_standard_pass_pct,
|
||||
p.ebacc_entry_pct,
|
||||
p.ebacc_strong_pass_pct,
|
||||
p.ebacc_standard_pass_pct,
|
||||
p.ebacc_avg_score,
|
||||
p.gcse_grade_91_pct,
|
||||
p.prior_attainment_avg,
|
||||
-- SEN (coalesced KS2+KS4 in fact_performance)
|
||||
p.sen_support_pct,
|
||||
p.sen_ehcp_pct
|
||||
FROM marts.dim_school s
|
||||
JOIN marts.dim_location l ON s.urn = l.urn
|
||||
LEFT JOIN marts.fact_performance p ON s.urn = p.urn
|
||||
ORDER BY s.school_name, p.year
|
||||
""")
|
||||
|
||||
|
||||
def get_available_years(db: Session = None) -> List[int]:
|
||||
"""Get list of available years in the database."""
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = get_db()
|
||||
|
||||
def load_school_data_as_dataframe() -> pd.DataFrame:
|
||||
"""Load all school + KS2 data as a pandas DataFrame."""
|
||||
try:
|
||||
result = db.query(SchoolResult.year).distinct().order_by(SchoolResult.year).all()
|
||||
return [r[0] for r in result]
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_available_local_authorities(db: Session = None) -> List[str]:
|
||||
"""Get list of available local authorities."""
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
result = db.query(School.local_authority)\
|
||||
.filter(School.local_authority.isnot(None))\
|
||||
.distinct()\
|
||||
.order_by(School.local_authority)\
|
||||
.all()
|
||||
return [r[0] for r in result if r[0]]
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_available_school_types(db: Session = None) -> List[str]:
|
||||
"""Get list of available school types (normalized to user-friendly names)."""
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
result = db.query(School.school_type)\
|
||||
.filter(School.school_type.isnot(None))\
|
||||
.distinct()\
|
||||
.all()
|
||||
# Normalize codes to friendly names and deduplicate
|
||||
normalized = set()
|
||||
for r in result:
|
||||
if r[0]:
|
||||
friendly_name = normalize_school_type(r[0])
|
||||
if friendly_name:
|
||||
normalized.add(friendly_name)
|
||||
return sorted(normalized)
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_schools_count(db: Session = None) -> int:
|
||||
"""Get total number of schools."""
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
return db.query(School).count()
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_schools(
|
||||
db: Session,
|
||||
search: Optional[str] = None,
|
||||
local_authority: Optional[str] = None,
|
||||
school_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[School], int]:
|
||||
"""
|
||||
Get paginated list of schools with optional filters.
|
||||
Returns (schools, total_count).
|
||||
"""
|
||||
query = db.query(School)
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
search_lower = f"%{search.lower()}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
func.lower(School.school_name).like(search_lower),
|
||||
func.lower(School.postcode).like(search_lower),
|
||||
func.lower(School.town).like(search_lower),
|
||||
)
|
||||
)
|
||||
|
||||
if local_authority:
|
||||
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
|
||||
|
||||
if school_type:
|
||||
# Filter by all codes that map to this friendly name
|
||||
type_codes = get_school_type_codes_for_filter(school_type)
|
||||
if type_codes:
|
||||
query = query.filter(func.lower(School.school_type).in_(type_codes))
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * page_size
|
||||
schools = query.order_by(School.school_name).offset(offset).limit(page_size).all()
|
||||
|
||||
return schools, total
|
||||
|
||||
|
||||
def get_schools_near_location(
|
||||
db: Session,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
radius_miles: float = 5.0,
|
||||
search: Optional[str] = None,
|
||||
local_authority: Optional[str] = None,
|
||||
school_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Tuple[School, float]], int]:
|
||||
"""
|
||||
Get schools near a location, sorted by distance.
|
||||
Returns list of (school, distance) tuples and total count.
|
||||
"""
|
||||
# Get all schools with coordinates
|
||||
query = db.query(School).filter(
|
||||
School.latitude.isnot(None),
|
||||
School.longitude.isnot(None)
|
||||
)
|
||||
|
||||
# Apply text filters
|
||||
if search:
|
||||
search_lower = f"%{search.lower()}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
func.lower(School.school_name).like(search_lower),
|
||||
func.lower(School.postcode).like(search_lower),
|
||||
func.lower(School.town).like(search_lower),
|
||||
)
|
||||
)
|
||||
|
||||
if local_authority:
|
||||
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
|
||||
|
||||
if school_type:
|
||||
# Filter by all codes that map to this friendly name
|
||||
type_codes = get_school_type_codes_for_filter(school_type)
|
||||
if type_codes:
|
||||
query = query.filter(func.lower(School.school_type).in_(type_codes))
|
||||
|
||||
# Get all matching schools and calculate distances
|
||||
all_schools = query.all()
|
||||
|
||||
schools_with_distance = []
|
||||
for school in all_schools:
|
||||
if school.latitude and school.longitude:
|
||||
dist = haversine_distance(latitude, longitude, school.latitude, school.longitude)
|
||||
if dist <= radius_miles:
|
||||
schools_with_distance.append((school, dist))
|
||||
|
||||
# Sort by distance
|
||||
schools_with_distance.sort(key=lambda x: x[1])
|
||||
|
||||
total = len(schools_with_distance)
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * page_size
|
||||
paginated = schools_with_distance[offset:offset + page_size]
|
||||
|
||||
return paginated, total
|
||||
|
||||
|
||||
def get_school_by_urn(db: Session, urn: int) -> Optional[School]:
|
||||
"""Get a single school by URN."""
|
||||
return db.query(School).filter(School.urn == urn).first()
|
||||
|
||||
|
||||
def get_school_results(
|
||||
db: Session,
|
||||
urn: int,
|
||||
years: Optional[List[int]] = None
|
||||
) -> List[SchoolResult]:
|
||||
"""Get all results for a school, optionally filtered by years."""
|
||||
query = db.query(SchoolResult)\
|
||||
.join(School)\
|
||||
.filter(School.urn == urn)\
|
||||
.order_by(SchoolResult.year)
|
||||
|
||||
if years:
|
||||
query = query.filter(SchoolResult.year.in_(years))
|
||||
|
||||
return query.all()
|
||||
|
||||
|
||||
def get_rankings(
|
||||
db: Session,
|
||||
metric: str,
|
||||
year: int,
|
||||
local_authority: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
ascending: bool = False,
|
||||
) -> List[Tuple[School, SchoolResult]]:
|
||||
"""
|
||||
Get school rankings for a specific metric and year.
|
||||
Returns list of (school, result) tuples.
|
||||
"""
|
||||
# Build the query
|
||||
query = db.query(School, SchoolResult)\
|
||||
.join(SchoolResult)\
|
||||
.filter(SchoolResult.year == year)
|
||||
|
||||
# Filter by local authority
|
||||
if local_authority:
|
||||
query = query.filter(func.lower(School.local_authority) == local_authority.lower())
|
||||
|
||||
# Get the metric column
|
||||
metric_column = getattr(SchoolResult, metric, None)
|
||||
if metric_column is None:
|
||||
return []
|
||||
|
||||
# Filter out nulls and order
|
||||
query = query.filter(metric_column.isnot(None))
|
||||
|
||||
if ascending:
|
||||
query = query.order_by(metric_column.asc())
|
||||
else:
|
||||
query = query.order_by(metric_column.desc())
|
||||
|
||||
return query.limit(limit).all()
|
||||
|
||||
|
||||
def get_data_info(db: Session = None) -> dict:
|
||||
"""Get information about the data in the database."""
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
school_count = db.query(School).count()
|
||||
result_count = db.query(SchoolResult).count()
|
||||
years = get_available_years(db)
|
||||
local_authorities = get_available_local_authorities(db)
|
||||
|
||||
return {
|
||||
"total_schools": school_count,
|
||||
"total_results": result_count,
|
||||
"years_available": years,
|
||||
"local_authorities_count": len(local_authorities),
|
||||
"data_source": "PostgreSQL",
|
||||
}
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
|
||||
def school_to_dict(school: School, include_results: bool = False) -> dict:
|
||||
"""Convert a School model to dictionary."""
|
||||
data = {
|
||||
"urn": school.urn,
|
||||
"school_name": school.school_name,
|
||||
"local_authority": school.local_authority,
|
||||
"school_type": normalize_school_type(school.school_type),
|
||||
"address": school.address,
|
||||
"town": school.town,
|
||||
"postcode": school.postcode,
|
||||
"latitude": school.latitude,
|
||||
"longitude": school.longitude,
|
||||
# GIAS fields
|
||||
"website": school.website,
|
||||
"headteacher_name": school.headteacher_name,
|
||||
"capacity": school.capacity,
|
||||
"trust_name": school.trust_name,
|
||||
"gender": school.gender,
|
||||
}
|
||||
|
||||
if include_results and school.results:
|
||||
data["results"] = [result_to_dict(r) for r in school.results]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def result_to_dict(result: SchoolResult) -> dict:
|
||||
"""Convert a SchoolResult model to dictionary."""
|
||||
return {
|
||||
"year": result.year,
|
||||
"total_pupils": result.total_pupils,
|
||||
"eligible_pupils": result.eligible_pupils,
|
||||
# Expected Standard
|
||||
"rwm_expected_pct": result.rwm_expected_pct,
|
||||
"reading_expected_pct": result.reading_expected_pct,
|
||||
"writing_expected_pct": result.writing_expected_pct,
|
||||
"maths_expected_pct": result.maths_expected_pct,
|
||||
"gps_expected_pct": result.gps_expected_pct,
|
||||
"science_expected_pct": result.science_expected_pct,
|
||||
# Higher Standard
|
||||
"rwm_high_pct": result.rwm_high_pct,
|
||||
"reading_high_pct": result.reading_high_pct,
|
||||
"writing_high_pct": result.writing_high_pct,
|
||||
"maths_high_pct": result.maths_high_pct,
|
||||
"gps_high_pct": result.gps_high_pct,
|
||||
# Progress
|
||||
"reading_progress": result.reading_progress,
|
||||
"writing_progress": result.writing_progress,
|
||||
"maths_progress": result.maths_progress,
|
||||
# Averages
|
||||
"reading_avg_score": result.reading_avg_score,
|
||||
"maths_avg_score": result.maths_avg_score,
|
||||
"gps_avg_score": result.gps_avg_score,
|
||||
# Context
|
||||
"disadvantaged_pct": result.disadvantaged_pct,
|
||||
"eal_pct": result.eal_pct,
|
||||
"sen_support_pct": result.sen_support_pct,
|
||||
"sen_ehcp_pct": result.sen_ehcp_pct,
|
||||
"stability_pct": result.stability_pct,
|
||||
# Gender
|
||||
"rwm_expected_boys_pct": result.rwm_expected_boys_pct,
|
||||
"rwm_expected_girls_pct": result.rwm_expected_girls_pct,
|
||||
"rwm_high_boys_pct": result.rwm_high_boys_pct,
|
||||
"rwm_high_girls_pct": result.rwm_high_girls_pct,
|
||||
# Disadvantaged
|
||||
"rwm_expected_disadvantaged_pct": result.rwm_expected_disadvantaged_pct,
|
||||
"rwm_expected_non_disadvantaged_pct": result.rwm_expected_non_disadvantaged_pct,
|
||||
"disadvantaged_gap": result.disadvantaged_gap,
|
||||
# 3-Year
|
||||
"rwm_expected_3yr_pct": result.rwm_expected_3yr_pct,
|
||||
"reading_avg_3yr": result.reading_avg_3yr,
|
||||
"maths_avg_3yr": result.maths_avg_3yr,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LEGACY COMPATIBILITY - DataFrame-based functions
|
||||
# =============================================================================
|
||||
|
||||
def load_school_data_as_dataframe(db: Session = None) -> pd.DataFrame:
|
||||
"""
|
||||
Load all school data as a pandas DataFrame.
|
||||
For compatibility with existing code that expects DataFrames.
|
||||
"""
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
# Query all schools with their results
|
||||
schools = db.query(School).options(joinedload(School.results)).all()
|
||||
|
||||
# Load Ofsted data into a lookup dict (urn → grade, date)
|
||||
ofsted_lookup: Dict[int, dict] = {}
|
||||
try:
|
||||
ofsted_rows = db.query(
|
||||
OfstedInspection.urn,
|
||||
OfstedInspection.overall_effectiveness,
|
||||
OfstedInspection.inspection_date,
|
||||
).all()
|
||||
for o in ofsted_rows:
|
||||
ofsted_lookup[o.urn] = {
|
||||
"ofsted_grade": o.overall_effectiveness,
|
||||
"ofsted_date": o.inspection_date.isoformat() if o.inspection_date else None,
|
||||
}
|
||||
except Exception:
|
||||
pass # Table may not exist yet on first run
|
||||
|
||||
rows = []
|
||||
for school in schools:
|
||||
ofsted = ofsted_lookup.get(school.urn, {})
|
||||
for result in school.results:
|
||||
row = {
|
||||
"urn": school.urn,
|
||||
"school_name": school.school_name,
|
||||
"local_authority": school.local_authority,
|
||||
"school_type": normalize_school_type(school.school_type),
|
||||
"address": school.address,
|
||||
"town": school.town,
|
||||
"postcode": school.postcode,
|
||||
"latitude": school.latitude,
|
||||
"longitude": school.longitude,
|
||||
# GIAS fields
|
||||
"website": school.website,
|
||||
"headteacher_name": school.headteacher_name,
|
||||
"capacity": school.capacity,
|
||||
"trust_name": school.trust_name,
|
||||
"gender": school.gender,
|
||||
# Ofsted (for list view)
|
||||
"ofsted_grade": ofsted.get("ofsted_grade"),
|
||||
"ofsted_date": ofsted.get("ofsted_date"),
|
||||
**result_to_dict(result)
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
if rows:
|
||||
return pd.DataFrame(rows)
|
||||
df = pd.read_sql(_MAIN_QUERY, engine)
|
||||
except Exception as exc:
|
||||
print(f"Warning: Could not load school data from marts: {exc}")
|
||||
return pd.DataFrame()
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
# Build address string
|
||||
df["address"] = df.apply(
|
||||
lambda r: ", ".join(
|
||||
p for p in [r.get("address1"), r.get("address2"), r.get("town"), r.get("postcode")]
|
||||
if p and str(p) != "None"
|
||||
),
|
||||
axis=1,
|
||||
)
|
||||
|
||||
# Normalize school type
|
||||
df["school_type"] = df["school_type"].apply(normalize_school_type)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
# Cache for DataFrame (legacy compatibility)
|
||||
# Cache for DataFrame
|
||||
_df_cache: Optional[pd.DataFrame] = None
|
||||
|
||||
|
||||
def load_school_data() -> pd.DataFrame:
|
||||
"""
|
||||
Legacy function to load school data as DataFrame.
|
||||
Uses caching for performance.
|
||||
"""
|
||||
"""Load school data with caching."""
|
||||
global _df_cache
|
||||
|
||||
if _df_cache is not None:
|
||||
return _df_cache
|
||||
|
||||
print("Loading school data from database...")
|
||||
print("Loading school data from marts...")
|
||||
_df_cache = load_school_data_as_dataframe()
|
||||
|
||||
if not _df_cache.empty:
|
||||
print(f"Total records loaded: {len(_df_cache)}")
|
||||
print(f"Unique schools: {_df_cache['urn'].nunique()}")
|
||||
print(f"Years: {sorted(_df_cache['year'].unique())}")
|
||||
print(f"Years: {sorted(_df_cache['year'].dropna().unique())}")
|
||||
else:
|
||||
print("No data found in database")
|
||||
|
||||
print("No data found in marts (EES data may not have been loaded yet)")
|
||||
return _df_cache
|
||||
|
||||
|
||||
@@ -549,44 +256,111 @@ def clear_cache():
|
||||
_df_cache = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# METADATA QUERIES
|
||||
# =============================================================================
|
||||
|
||||
def get_available_years(db: Session = None) -> List[int]:
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.query(KS2Performance.year).distinct().order_by(KS2Performance.year).all()
|
||||
return [r[0] for r in result]
|
||||
except Exception:
|
||||
return []
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_available_local_authorities(db: Session = None) -> List[str]:
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = (
|
||||
db.query(DimLocation.local_authority_name)
|
||||
.filter(DimLocation.local_authority_name.isnot(None))
|
||||
.distinct()
|
||||
.order_by(DimLocation.local_authority_name)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in result if r[0]]
|
||||
except Exception:
|
||||
return []
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_schools_count(db: Session = None) -> int:
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.query(DimSchool).count()
|
||||
except Exception:
|
||||
return 0
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_data_info(db: Session = None) -> dict:
|
||||
close_db = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
school_count = get_schools_count(db)
|
||||
years = get_available_years(db)
|
||||
local_authorities = get_available_local_authorities(db)
|
||||
return {
|
||||
"total_schools": school_count,
|
||||
"years_available": years,
|
||||
"local_authorities_count": len(local_authorities),
|
||||
"data_source": "PostgreSQL (marts)",
|
||||
}
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUPPLEMENTARY DATA — per-school detail page
|
||||
# =============================================================================
|
||||
|
||||
def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
"""
|
||||
Fetch all supplementary data for a single school URN.
|
||||
Returns a dict with keys: ofsted, parent_view, census, admissions, sen_detail,
|
||||
phonics, deprivation, finance. Values are dicts or None.
|
||||
"""
|
||||
"""Fetch all supplementary data for a single school URN."""
|
||||
result = {}
|
||||
|
||||
def safe_query(model, pk_field, latest_year_field=None):
|
||||
def safe_query(model, pk_field, latest_field=None):
|
||||
try:
|
||||
if latest_year_field:
|
||||
row = (
|
||||
db.query(model)
|
||||
.filter(getattr(model, pk_field) == urn)
|
||||
.order_by(getattr(model, latest_year_field).desc())
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
row = db.query(model).filter(getattr(model, pk_field) == urn).first()
|
||||
return row
|
||||
except Exception:
|
||||
q = db.query(model).filter(getattr(model, pk_field) == urn)
|
||||
if latest_field:
|
||||
q = q.order_by(getattr(model, latest_field).desc())
|
||||
return q.first()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error("safe_query failed for %s: %s", model.__name__, e)
|
||||
db.rollback()
|
||||
return None
|
||||
|
||||
# Ofsted inspection
|
||||
o = safe_query(OfstedInspection, "urn")
|
||||
result["ofsted"] = {
|
||||
# Latest Ofsted inspection
|
||||
o = safe_query(FactOfstedInspection, "urn", "inspection_date")
|
||||
result["ofsted"] = (
|
||||
{
|
||||
"framework": o.framework,
|
||||
"inspection_date": o.inspection_date.isoformat() if o.inspection_date else None,
|
||||
"inspection_type": o.inspection_type,
|
||||
# OEIF fields (old framework)
|
||||
"overall_effectiveness": o.overall_effectiveness,
|
||||
"quality_of_education": o.quality_of_education,
|
||||
"behaviour_attitudes": o.behaviour_attitudes,
|
||||
"personal_development": o.personal_development,
|
||||
"leadership_management": o.leadership_management,
|
||||
"early_years_provision": o.early_years_provision,
|
||||
"previous_overall": o.previous_overall,
|
||||
# Report Card fields (new framework, from Nov 2025)
|
||||
"sixth_form_provision": o.sixth_form_provision,
|
||||
"previous_overall": None, # Not available in new schema
|
||||
"rc_safeguarding_met": o.rc_safeguarding_met,
|
||||
"rc_inclusion": o.rc_inclusion,
|
||||
"rc_curriculum_teaching": o.rc_curriculum_teaching,
|
||||
@@ -596,11 +370,16 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
"rc_leadership_governance": o.rc_leadership_governance,
|
||||
"rc_early_years": o.rc_early_years,
|
||||
"rc_sixth_form": o.rc_sixth_form,
|
||||
} if o else None
|
||||
"report_url": o.report_url,
|
||||
}
|
||||
if o
|
||||
else None
|
||||
)
|
||||
|
||||
# Parent View
|
||||
pv = safe_query(OfstedParentView, "urn")
|
||||
result["parent_view"] = {
|
||||
pv = safe_query(FactParentView, "urn")
|
||||
result["parent_view"] = (
|
||||
{
|
||||
"survey_date": pv.survey_date.isoformat() if pv.survey_date else None,
|
||||
"total_responses": pv.total_responses,
|
||||
"q_happy_pct": pv.q_happy_pct,
|
||||
@@ -616,69 +395,62 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
"q_leadership_pct": pv.q_leadership_pct,
|
||||
"q_wellbeing_pct": pv.q_wellbeing_pct,
|
||||
"q_recommend_pct": pv.q_recommend_pct,
|
||||
"q_sen_pct": pv.q_sen_pct,
|
||||
} if pv else None
|
||||
}
|
||||
if pv
|
||||
else None
|
||||
)
|
||||
|
||||
# School Census (latest year)
|
||||
c = safe_query(SchoolCensus, "urn", "year")
|
||||
result["census"] = {
|
||||
"year": c.year,
|
||||
"class_size_avg": c.class_size_avg,
|
||||
"ethnicity_white_pct": c.ethnicity_white_pct,
|
||||
"ethnicity_asian_pct": c.ethnicity_asian_pct,
|
||||
"ethnicity_black_pct": c.ethnicity_black_pct,
|
||||
"ethnicity_mixed_pct": c.ethnicity_mixed_pct,
|
||||
"ethnicity_other_pct": c.ethnicity_other_pct,
|
||||
} if c else None
|
||||
# Census (fact_pupil_characteristics — minimal until census columns are verified)
|
||||
result["census"] = None
|
||||
|
||||
# Admissions (latest year)
|
||||
a = safe_query(SchoolAdmissions, "urn", "year")
|
||||
result["admissions"] = {
|
||||
a = safe_query(FactAdmissions, "urn", "year")
|
||||
result["admissions"] = (
|
||||
{
|
||||
"year": a.year,
|
||||
"school_phase": a.school_phase,
|
||||
"published_admission_number": a.published_admission_number,
|
||||
"total_applications": a.total_applications,
|
||||
"first_preference_offers_pct": a.first_preference_offers_pct,
|
||||
"first_preference_applications": a.first_preference_applications,
|
||||
"first_preference_offers": a.first_preference_offers,
|
||||
"first_preference_offer_pct": a.first_preference_offer_pct,
|
||||
"oversubscribed": a.oversubscribed,
|
||||
} if a else None
|
||||
}
|
||||
if a
|
||||
else None
|
||||
)
|
||||
|
||||
# SEN Detail (latest year)
|
||||
s = safe_query(SenDetail, "urn", "year")
|
||||
result["sen_detail"] = {
|
||||
"year": s.year,
|
||||
"primary_need_speech_pct": s.primary_need_speech_pct,
|
||||
"primary_need_autism_pct": s.primary_need_autism_pct,
|
||||
"primary_need_mld_pct": s.primary_need_mld_pct,
|
||||
"primary_need_spld_pct": s.primary_need_spld_pct,
|
||||
"primary_need_semh_pct": s.primary_need_semh_pct,
|
||||
"primary_need_physical_pct": s.primary_need_physical_pct,
|
||||
"primary_need_other_pct": s.primary_need_other_pct,
|
||||
} if s else None
|
||||
# SEN detail — not available in current marts
|
||||
result["sen_detail"] = None
|
||||
|
||||
# Phonics (latest year)
|
||||
ph = safe_query(Phonics, "urn", "year")
|
||||
result["phonics"] = {
|
||||
"year": ph.year,
|
||||
"year1_phonics_pct": ph.year1_phonics_pct,
|
||||
"year2_phonics_pct": ph.year2_phonics_pct,
|
||||
} if ph else None
|
||||
# Phonics — no school-level data on EES
|
||||
result["phonics"] = None
|
||||
|
||||
# Deprivation
|
||||
d = safe_query(SchoolDeprivation, "urn")
|
||||
result["deprivation"] = {
|
||||
d = safe_query(FactDeprivation, "urn")
|
||||
result["deprivation"] = (
|
||||
{
|
||||
"lsoa_code": d.lsoa_code,
|
||||
"idaci_score": d.idaci_score,
|
||||
"idaci_decile": d.idaci_decile,
|
||||
} if d else None
|
||||
}
|
||||
if d
|
||||
else None
|
||||
)
|
||||
|
||||
# Finance (latest year)
|
||||
f = safe_query(SchoolFinance, "urn", "year")
|
||||
result["finance"] = {
|
||||
f = safe_query(FactFinance, "urn", "year")
|
||||
result["finance"] = (
|
||||
{
|
||||
"year": f.year,
|
||||
"per_pupil_spend": f.per_pupil_spend,
|
||||
"staff_cost_pct": f.staff_cost_pct,
|
||||
"teacher_cost_pct": f.teacher_cost_pct,
|
||||
"support_staff_cost_pct": f.support_staff_cost_pct,
|
||||
"premises_cost_pct": f.premises_cost_pct,
|
||||
} if f else None
|
||||
}
|
||||
if f
|
||||
else None
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
"""
|
||||
Database connection setup using SQLAlchemy.
|
||||
The schema is managed by dbt — the backend only reads from marts.* tables.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
from .config import settings
|
||||
|
||||
# Create engine
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True, # Verify connections before use
|
||||
echo=False, # Set to True for SQL debugging
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""
|
||||
Dependency for FastAPI routes to get a database session.
|
||||
"""
|
||||
"""Dependency for FastAPI routes."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
@@ -40,10 +34,7 @@ def get_db():
|
||||
|
||||
@contextmanager
|
||||
def get_db_session():
|
||||
"""
|
||||
Context manager for database sessions.
|
||||
Use in non-FastAPI contexts (scripts, etc).
|
||||
"""
|
||||
"""Context manager for non-FastAPI contexts."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
@@ -53,95 +44,3 @@ def get_db_session():
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""
|
||||
Initialize database - create all tables.
|
||||
"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def drop_db():
|
||||
"""
|
||||
Drop all tables - use with caution!
|
||||
"""
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def get_db_schema_version() -> Optional[int]:
|
||||
"""
|
||||
Get the current schema version from the database.
|
||||
Returns None if table doesn't exist or no version is set.
|
||||
"""
|
||||
from .models import SchemaVersion # Import here to avoid circular imports
|
||||
|
||||
# Check if schema_version table exists
|
||||
inspector = inspect(engine)
|
||||
if "schema_version" not in inspector.get_table_names():
|
||||
return None
|
||||
|
||||
try:
|
||||
with get_db_session() as db:
|
||||
row = db.query(SchemaVersion).first()
|
||||
return row.version if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def set_db_schema_version(version: int):
|
||||
"""
|
||||
Set/update the schema version in the database.
|
||||
Creates the row if it doesn't exist.
|
||||
"""
|
||||
from .models import SchemaVersion
|
||||
|
||||
with get_db_session() as db:
|
||||
row = db.query(SchemaVersion).first()
|
||||
if row:
|
||||
row.version = version
|
||||
row.migrated_at = datetime.utcnow()
|
||||
else:
|
||||
db.add(SchemaVersion(id=1, version=version, migrated_at=datetime.utcnow()))
|
||||
|
||||
|
||||
def check_and_migrate_if_needed():
|
||||
"""
|
||||
Check schema version and run migration if needed.
|
||||
Called during application startup.
|
||||
"""
|
||||
from .version import SCHEMA_VERSION
|
||||
from .migration import run_full_migration
|
||||
|
||||
db_version = get_db_schema_version()
|
||||
|
||||
if db_version == SCHEMA_VERSION:
|
||||
print(f"Schema version {SCHEMA_VERSION} matches. Fast startup.")
|
||||
# Still ensure tables exist (they should if version matches)
|
||||
init_db()
|
||||
return
|
||||
|
||||
if db_version is None:
|
||||
print(f"No schema version found. Running initial migration (v{SCHEMA_VERSION})...")
|
||||
else:
|
||||
print(f"Schema mismatch: DB has v{db_version}, code expects v{SCHEMA_VERSION}")
|
||||
print("Running full migration...")
|
||||
|
||||
try:
|
||||
# Set schema version BEFORE migration so a crash mid-migration
|
||||
# doesn't cause an infinite re-migration loop on every restart.
|
||||
init_db()
|
||||
set_db_schema_version(SCHEMA_VERSION)
|
||||
|
||||
success = run_full_migration(geocode=False)
|
||||
|
||||
if success:
|
||||
print(f"Migration complete. Schema version {SCHEMA_VERSION}.")
|
||||
else:
|
||||
print("Warning: Migration completed but no data was imported.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"FATAL: Migration failed: {e}")
|
||||
print("Application cannot start. Please check database and CSV files.")
|
||||
raise
|
||||
|
||||
|
||||
@@ -1,408 +1,216 @@
|
||||
"""
|
||||
SQLAlchemy database models for school data.
|
||||
Normalized schema with separate tables for schools and yearly results.
|
||||
SQLAlchemy models — all tables live in the marts schema, built by dbt.
|
||||
Read-only: the pipeline writes to these tables; the backend only reads.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, Date, Text, Index
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Float, ForeignKey, Index, UniqueConstraint,
|
||||
Text, Boolean, DateTime, Date
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from .database import Base
|
||||
|
||||
MARTS = {"schema": "marts"}
|
||||
|
||||
class School(Base):
|
||||
"""
|
||||
Core school information - relatively static data.
|
||||
"""
|
||||
__tablename__ = "schools"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
urn = Column(Integer, unique=True, nullable=False, index=True)
|
||||
class DimSchool(Base):
|
||||
"""Canonical school dimension — one row per active URN."""
|
||||
__tablename__ = "dim_school"
|
||||
__table_args__ = MARTS
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
school_name = Column(String(255), nullable=False)
|
||||
local_authority = Column(String(100))
|
||||
local_authority_code = Column(Integer)
|
||||
phase = Column(String(100))
|
||||
school_type = Column(String(100))
|
||||
school_type_code = Column(String(10))
|
||||
religious_denomination = Column(String(100))
|
||||
academy_trust_name = Column(String(255))
|
||||
academy_trust_uid = Column(String(20))
|
||||
religious_character = Column(String(100))
|
||||
gender = Column(String(20))
|
||||
age_range = Column(String(20))
|
||||
capacity = Column(Integer)
|
||||
total_pupils = Column(Integer)
|
||||
headteacher_name = Column(String(200))
|
||||
website = Column(String(255))
|
||||
telephone = Column(String(30))
|
||||
status = Column(String(50))
|
||||
nursery_provision = Column(Boolean)
|
||||
admissions_policy = Column(String(50))
|
||||
# Denormalised Ofsted summary (updated by monthly pipeline)
|
||||
ofsted_grade = Column(Integer)
|
||||
ofsted_date = Column(Date)
|
||||
ofsted_framework = Column(String(20))
|
||||
|
||||
# Address
|
||||
address1 = Column(String(255))
|
||||
address2 = Column(String(255))
|
||||
|
||||
class DimLocation(Base):
|
||||
"""School location — address, lat/lng from easting/northing (BNG→WGS84)."""
|
||||
__tablename__ = "dim_location"
|
||||
__table_args__ = MARTS
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
address_line1 = Column(String(255))
|
||||
address_line2 = Column(String(255))
|
||||
town = Column(String(100))
|
||||
postcode = Column(String(20), index=True)
|
||||
|
||||
# Geocoding (cached)
|
||||
county = Column(String(100))
|
||||
postcode = Column(String(20))
|
||||
local_authority_code = Column(Integer)
|
||||
local_authority_name = Column(String(100))
|
||||
parliamentary_constituency = Column(String(100))
|
||||
urban_rural = Column(String(50))
|
||||
easting = Column(Integer)
|
||||
northing = Column(Integer)
|
||||
latitude = Column(Float)
|
||||
longitude = Column(Float)
|
||||
|
||||
# GIAS enrichment fields
|
||||
website = Column(String(255))
|
||||
headteacher_name = Column(String(200))
|
||||
capacity = Column(Integer)
|
||||
trust_name = Column(String(255))
|
||||
trust_uid = Column(String(20))
|
||||
gender = Column(String(20)) # Mixed / Girls / Boys
|
||||
nursery_provision = Column(Boolean)
|
||||
|
||||
# Relationships
|
||||
results = relationship("SchoolResult", back_populates="school", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<School(urn={self.urn}, name='{self.school_name}')>"
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
"""Combine address fields into single string."""
|
||||
parts = [self.address1, self.address2, self.town, self.postcode]
|
||||
return ", ".join(p for p in parts if p)
|
||||
# geom is a PostGIS geometry — not mapped to SQLAlchemy (accessed via raw SQL)
|
||||
|
||||
|
||||
class SchoolResult(Base):
|
||||
"""
|
||||
Yearly KS2 results for a school.
|
||||
Each school can have multiple years of results.
|
||||
"""
|
||||
__tablename__ = "school_results"
|
||||
class KS2Performance(Base):
|
||||
"""KS2 attainment — one row per URN per year (includes predecessor data)."""
|
||||
__tablename__ = "fact_ks2_performance"
|
||||
__table_args__ = (
|
||||
Index("ix_ks2_urn_year", "urn", "year"),
|
||||
MARTS,
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
school_id = Column(Integer, ForeignKey("schools.id", ondelete="CASCADE"), nullable=False)
|
||||
year = Column(Integer, nullable=False, index=True)
|
||||
|
||||
# Pupil numbers
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
source_urn = Column(Integer)
|
||||
total_pupils = Column(Integer)
|
||||
eligible_pupils = Column(Integer)
|
||||
|
||||
# Core KS2 metrics - Expected Standard
|
||||
# Core attainment
|
||||
rwm_expected_pct = Column(Float)
|
||||
reading_expected_pct = Column(Float)
|
||||
writing_expected_pct = Column(Float)
|
||||
maths_expected_pct = Column(Float)
|
||||
gps_expected_pct = Column(Float)
|
||||
science_expected_pct = Column(Float)
|
||||
|
||||
# Higher Standard
|
||||
rwm_high_pct = Column(Float)
|
||||
reading_expected_pct = Column(Float)
|
||||
reading_high_pct = Column(Float)
|
||||
writing_high_pct = Column(Float)
|
||||
maths_high_pct = Column(Float)
|
||||
gps_high_pct = Column(Float)
|
||||
|
||||
# Progress Scores
|
||||
reading_progress = Column(Float)
|
||||
writing_progress = Column(Float)
|
||||
maths_progress = Column(Float)
|
||||
|
||||
# Average Scores
|
||||
reading_avg_score = Column(Float)
|
||||
reading_progress = Column(Float)
|
||||
writing_expected_pct = Column(Float)
|
||||
writing_high_pct = Column(Float)
|
||||
writing_progress = Column(Float)
|
||||
maths_expected_pct = Column(Float)
|
||||
maths_high_pct = Column(Float)
|
||||
maths_avg_score = Column(Float)
|
||||
maths_progress = Column(Float)
|
||||
gps_expected_pct = Column(Float)
|
||||
gps_high_pct = Column(Float)
|
||||
gps_avg_score = Column(Float)
|
||||
|
||||
# School Context
|
||||
science_expected_pct = Column(Float)
|
||||
# Absence
|
||||
reading_absence_pct = Column(Float)
|
||||
writing_absence_pct = Column(Float)
|
||||
maths_absence_pct = Column(Float)
|
||||
gps_absence_pct = Column(Float)
|
||||
science_absence_pct = Column(Float)
|
||||
# Gender
|
||||
rwm_expected_boys_pct = Column(Float)
|
||||
rwm_high_boys_pct = Column(Float)
|
||||
rwm_expected_girls_pct = Column(Float)
|
||||
rwm_high_girls_pct = Column(Float)
|
||||
# Disadvantaged
|
||||
rwm_expected_disadvantaged_pct = Column(Float)
|
||||
rwm_expected_non_disadvantaged_pct = Column(Float)
|
||||
disadvantaged_gap = Column(Float)
|
||||
# Context
|
||||
disadvantaged_pct = Column(Float)
|
||||
eal_pct = Column(Float)
|
||||
sen_support_pct = Column(Float)
|
||||
sen_ehcp_pct = Column(Float)
|
||||
stability_pct = Column(Float)
|
||||
|
||||
# Pupil Absence from Tests
|
||||
reading_absence_pct = Column(Float)
|
||||
gps_absence_pct = Column(Float)
|
||||
maths_absence_pct = Column(Float)
|
||||
writing_absence_pct = Column(Float)
|
||||
science_absence_pct = Column(Float)
|
||||
|
||||
# Gender Breakdown
|
||||
rwm_expected_boys_pct = Column(Float)
|
||||
rwm_expected_girls_pct = Column(Float)
|
||||
rwm_high_boys_pct = Column(Float)
|
||||
rwm_high_girls_pct = Column(Float)
|
||||
|
||||
# Disadvantaged Performance
|
||||
rwm_expected_disadvantaged_pct = Column(Float)
|
||||
rwm_expected_non_disadvantaged_pct = Column(Float)
|
||||
disadvantaged_gap = Column(Float)
|
||||
|
||||
# 3-Year Averages
|
||||
rwm_expected_3yr_pct = Column(Float)
|
||||
reading_avg_3yr = Column(Float)
|
||||
maths_avg_3yr = Column(Float)
|
||||
|
||||
# Relationship
|
||||
school = relationship("School", back_populates="results")
|
||||
|
||||
# Constraints
|
||||
class FactOfstedInspection(Base):
|
||||
"""Full Ofsted inspection history — one row per inspection."""
|
||||
__tablename__ = "fact_ofsted_inspection"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('school_id', 'year', name='uq_school_year'),
|
||||
Index('ix_school_results_school_year', 'school_id', 'year'),
|
||||
Index("ix_ofsted_urn_date", "urn", "inspection_date"),
|
||||
MARTS,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SchoolResult(school_id={self.school_id}, year={self.year})>"
|
||||
|
||||
|
||||
class SchemaVersion(Base):
|
||||
"""
|
||||
Tracks database schema version for automatic migrations.
|
||||
Single-row table that stores the current schema version.
|
||||
"""
|
||||
__tablename__ = "schema_version"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
version = Column(Integer, nullable=False)
|
||||
migrated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SchemaVersion(version={self.version}, migrated_at={self.migrated_at})>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supplementary data tables (populated by the Kestra data integrator)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OfstedInspection(Base):
|
||||
"""Latest Ofsted inspection judgement per school."""
|
||||
__tablename__ = "ofsted_inspections"
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
inspection_date = Column(Date)
|
||||
publication_date = Column(Date)
|
||||
inspection_type = Column(String(100)) # Section 5 / Section 8 etc.
|
||||
# Which inspection framework was used: 'OEIF' or 'ReportCard'
|
||||
inspection_date = Column(Date, primary_key=True)
|
||||
inspection_type = Column(String(100))
|
||||
framework = Column(String(20))
|
||||
|
||||
# --- OEIF grades (old framework, pre-Nov 2025) ---
|
||||
# 1=Outstanding 2=Good 3=Requires improvement 4=Inadequate
|
||||
overall_effectiveness = Column(Integer)
|
||||
quality_of_education = Column(Integer)
|
||||
behaviour_attitudes = Column(Integer)
|
||||
personal_development = Column(Integer)
|
||||
leadership_management = Column(Integer)
|
||||
early_years_provision = Column(Integer) # nullable — not all schools
|
||||
previous_overall = Column(Integer) # for trend display
|
||||
|
||||
# --- Report Card grades (new framework, from Nov 2025) ---
|
||||
# 1=Exceptional 2=Strong 3=Expected standard 4=Needs attention 5=Urgent improvement
|
||||
rc_safeguarding_met = Column(Boolean) # True=Met, False=Not met
|
||||
early_years_provision = Column(Integer)
|
||||
sixth_form_provision = Column(Integer)
|
||||
rc_safeguarding_met = Column(Boolean)
|
||||
rc_inclusion = Column(Integer)
|
||||
rc_curriculum_teaching = Column(Integer)
|
||||
rc_achievement = Column(Integer)
|
||||
rc_attendance_behaviour = Column(Integer)
|
||||
rc_personal_development = Column(Integer)
|
||||
rc_leadership_governance = Column(Integer)
|
||||
rc_early_years = Column(Integer) # nullable — not all schools
|
||||
rc_sixth_form = Column(Integer) # nullable — secondary only
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OfstedInspection(urn={self.urn}, framework={self.framework}, overall={self.overall_effectiveness})>"
|
||||
rc_early_years = Column(Integer)
|
||||
rc_sixth_form = Column(Integer)
|
||||
report_url = Column(Text)
|
||||
|
||||
|
||||
class OfstedParentView(Base):
|
||||
"""Ofsted Parent View survey — latest per school. 14 questions, % saying Yes."""
|
||||
__tablename__ = "ofsted_parent_view"
|
||||
class FactParentView(Base):
|
||||
"""Ofsted Parent View survey — latest per school."""
|
||||
__tablename__ = "fact_parent_view"
|
||||
__table_args__ = MARTS
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
survey_date = Column(Date)
|
||||
total_responses = Column(Integer)
|
||||
q_happy_pct = Column(Float) # My child is happy at this school
|
||||
q_safe_pct = Column(Float) # My child feels safe at this school
|
||||
q_bullying_pct = Column(Float) # School deals with bullying well
|
||||
q_communication_pct = Column(Float) # School keeps me informed
|
||||
q_progress_pct = Column(Float) # My child does well / good progress
|
||||
q_teaching_pct = Column(Float) # Teaching is good
|
||||
q_information_pct = Column(Float) # I receive valuable info about progress
|
||||
q_curriculum_pct = Column(Float) # Broad range of subjects taught
|
||||
q_future_pct = Column(Float) # Prepares child well for the future
|
||||
q_leadership_pct = Column(Float) # Led and managed effectively
|
||||
q_wellbeing_pct = Column(Float) # Supports wider personal development
|
||||
q_behaviour_pct = Column(Float) # Pupils are well behaved
|
||||
q_recommend_pct = Column(Float) # I would recommend this school
|
||||
q_sen_pct = Column(Float) # Good information about child's SEN (where applicable)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OfstedParentView(urn={self.urn}, responses={self.total_responses})>"
|
||||
q_happy_pct = Column(Float)
|
||||
q_safe_pct = Column(Float)
|
||||
q_behaviour_pct = Column(Float)
|
||||
q_bullying_pct = Column(Float)
|
||||
q_communication_pct = Column(Float)
|
||||
q_progress_pct = Column(Float)
|
||||
q_teaching_pct = Column(Float)
|
||||
q_information_pct = Column(Float)
|
||||
q_curriculum_pct = Column(Float)
|
||||
q_future_pct = Column(Float)
|
||||
q_leadership_pct = Column(Float)
|
||||
q_wellbeing_pct = Column(Float)
|
||||
q_recommend_pct = Column(Float)
|
||||
|
||||
|
||||
class SchoolCensus(Base):
|
||||
"""Annual school census snapshot — class sizes and ethnicity breakdown."""
|
||||
__tablename__ = "school_census"
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
class_size_avg = Column(Float)
|
||||
ethnicity_white_pct = Column(Float)
|
||||
ethnicity_asian_pct = Column(Float)
|
||||
ethnicity_black_pct = Column(Float)
|
||||
ethnicity_mixed_pct = Column(Float)
|
||||
ethnicity_other_pct = Column(Float)
|
||||
|
||||
class FactAdmissions(Base):
|
||||
"""School admissions — one row per URN per year."""
|
||||
__tablename__ = "fact_admissions"
|
||||
__table_args__ = (
|
||||
Index('ix_school_census_urn_year', 'urn', 'year'),
|
||||
Index("ix_admissions_urn_year", "urn", "year"),
|
||||
MARTS,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SchoolCensus(urn={self.urn}, year={self.year})>"
|
||||
|
||||
|
||||
class SchoolAdmissions(Base):
|
||||
"""Annual admissions statistics per school."""
|
||||
__tablename__ = "school_admissions"
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
published_admission_number = Column(Integer) # PAN
|
||||
school_phase = Column(String(50))
|
||||
published_admission_number = Column(Integer)
|
||||
total_applications = Column(Integer)
|
||||
first_preference_offers_pct = Column(Float) # % receiving 1st choice
|
||||
first_preference_applications = Column(Integer)
|
||||
first_preference_offers = Column(Integer)
|
||||
first_preference_offer_pct = Column(Float)
|
||||
oversubscribed = Column(Boolean)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_school_admissions_urn_year', 'urn', 'year'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SchoolAdmissions(urn={self.urn}, year={self.year})>"
|
||||
admissions_policy = Column(String(100))
|
||||
|
||||
|
||||
class SenDetail(Base):
|
||||
"""SEN primary need type breakdown — more granular than school_results context fields."""
|
||||
__tablename__ = "sen_detail"
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
primary_need_speech_pct = Column(Float) # SLCN
|
||||
primary_need_autism_pct = Column(Float) # ASD
|
||||
primary_need_mld_pct = Column(Float) # Moderate learning difficulty
|
||||
primary_need_spld_pct = Column(Float) # Specific learning difficulty (dyslexia etc.)
|
||||
primary_need_semh_pct = Column(Float) # Social, emotional, mental health
|
||||
primary_need_physical_pct = Column(Float) # Physical/sensory
|
||||
primary_need_other_pct = Column(Float)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_sen_detail_urn_year', 'urn', 'year'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SenDetail(urn={self.urn}, year={self.year})>"
|
||||
|
||||
|
||||
class Phonics(Base):
|
||||
"""Phonics Screening Check pass rates."""
|
||||
__tablename__ = "phonics"
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
year1_phonics_pct = Column(Float) # % reaching expected standard in Year 1
|
||||
year2_phonics_pct = Column(Float) # % reaching standard in Year 2 (re-takers)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_phonics_urn_year', 'urn', 'year'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Phonics(urn={self.urn}, year={self.year})>"
|
||||
|
||||
|
||||
class SchoolDeprivation(Base):
|
||||
"""IDACI deprivation index — derived via postcode → LSOA lookup."""
|
||||
__tablename__ = "school_deprivation"
|
||||
class FactDeprivation(Base):
|
||||
"""IDACI deprivation index — one row per URN."""
|
||||
__tablename__ = "fact_deprivation"
|
||||
__table_args__ = MARTS
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
lsoa_code = Column(String(20))
|
||||
idaci_score = Column(Float) # 0–1, higher = more deprived
|
||||
idaci_decile = Column(Integer) # 1 = most deprived, 10 = least deprived
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SchoolDeprivation(urn={self.urn}, decile={self.idaci_decile})>"
|
||||
idaci_score = Column(Float)
|
||||
idaci_decile = Column(Integer)
|
||||
|
||||
|
||||
class SchoolFinance(Base):
|
||||
"""FBIT financial benchmarking data."""
|
||||
__tablename__ = "school_finance"
|
||||
class FactFinance(Base):
|
||||
"""FBIT financial benchmarking — one row per URN per year."""
|
||||
__tablename__ = "fact_finance"
|
||||
__table_args__ = (
|
||||
Index("ix_finance_urn_year", "urn", "year"),
|
||||
MARTS,
|
||||
)
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
per_pupil_spend = Column(Float) # £ total expenditure per pupil
|
||||
staff_cost_pct = Column(Float) # % of budget on all staff
|
||||
teacher_cost_pct = Column(Float) # % on teachers specifically
|
||||
per_pupil_spend = Column(Float)
|
||||
staff_cost_pct = Column(Float)
|
||||
teacher_cost_pct = Column(Float)
|
||||
support_staff_cost_pct = Column(Float)
|
||||
premises_cost_pct = Column(Float)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_school_finance_urn_year', 'urn', 'year'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SchoolFinance(urn={self.urn}, year={self.year})>"
|
||||
|
||||
|
||||
# Mapping from CSV columns to model fields
|
||||
SCHOOL_FIELD_MAPPING = {
|
||||
'urn': 'urn',
|
||||
'school_name': 'school_name',
|
||||
'local_authority': 'local_authority',
|
||||
'local_authority_code': 'local_authority_code',
|
||||
'school_type': 'school_type',
|
||||
'school_type_code': 'school_type_code',
|
||||
'religious_denomination': 'religious_denomination',
|
||||
'age_range': 'age_range',
|
||||
'address1': 'address1',
|
||||
'address2': 'address2',
|
||||
'town': 'town',
|
||||
'postcode': 'postcode',
|
||||
}
|
||||
|
||||
RESULT_FIELD_MAPPING = {
|
||||
'year': 'year',
|
||||
'total_pupils': 'total_pupils',
|
||||
'eligible_pupils': 'eligible_pupils',
|
||||
# Expected Standard
|
||||
'rwm_expected_pct': 'rwm_expected_pct',
|
||||
'reading_expected_pct': 'reading_expected_pct',
|
||||
'writing_expected_pct': 'writing_expected_pct',
|
||||
'maths_expected_pct': 'maths_expected_pct',
|
||||
'gps_expected_pct': 'gps_expected_pct',
|
||||
'science_expected_pct': 'science_expected_pct',
|
||||
# Higher Standard
|
||||
'rwm_high_pct': 'rwm_high_pct',
|
||||
'reading_high_pct': 'reading_high_pct',
|
||||
'writing_high_pct': 'writing_high_pct',
|
||||
'maths_high_pct': 'maths_high_pct',
|
||||
'gps_high_pct': 'gps_high_pct',
|
||||
# Progress
|
||||
'reading_progress': 'reading_progress',
|
||||
'writing_progress': 'writing_progress',
|
||||
'maths_progress': 'maths_progress',
|
||||
# Averages
|
||||
'reading_avg_score': 'reading_avg_score',
|
||||
'maths_avg_score': 'maths_avg_score',
|
||||
'gps_avg_score': 'gps_avg_score',
|
||||
# Context
|
||||
'disadvantaged_pct': 'disadvantaged_pct',
|
||||
'eal_pct': 'eal_pct',
|
||||
'sen_support_pct': 'sen_support_pct',
|
||||
'sen_ehcp_pct': 'sen_ehcp_pct',
|
||||
'stability_pct': 'stability_pct',
|
||||
# Absence
|
||||
'reading_absence_pct': 'reading_absence_pct',
|
||||
'gps_absence_pct': 'gps_absence_pct',
|
||||
'maths_absence_pct': 'maths_absence_pct',
|
||||
'writing_absence_pct': 'writing_absence_pct',
|
||||
'science_absence_pct': 'science_absence_pct',
|
||||
# Gender
|
||||
'rwm_expected_boys_pct': 'rwm_expected_boys_pct',
|
||||
'rwm_expected_girls_pct': 'rwm_expected_girls_pct',
|
||||
'rwm_high_boys_pct': 'rwm_high_boys_pct',
|
||||
'rwm_high_girls_pct': 'rwm_high_girls_pct',
|
||||
# Disadvantaged
|
||||
'rwm_expected_disadvantaged_pct': 'rwm_expected_disadvantaged_pct',
|
||||
'rwm_expected_non_disadvantaged_pct': 'rwm_expected_non_disadvantaged_pct',
|
||||
'disadvantaged_gap': 'disadvantaged_gap',
|
||||
# 3-Year
|
||||
'rwm_expected_3yr_pct': 'rwm_expected_3yr_pct',
|
||||
'reading_avg_3yr': 'reading_avg_3yr',
|
||||
'maths_avg_3yr': 'maths_avg_3yr',
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +401,70 @@ METRIC_DEFINITIONS = {
|
||||
"type": "score",
|
||||
"category": "trends",
|
||||
},
|
||||
# ── GCSE Performance (KS4) ────────────────────────────────────────────
|
||||
"attainment_8_score": {
|
||||
"name": "Attainment 8",
|
||||
"short_name": "Att 8",
|
||||
"description": "Average grade across a pupil's best 8 GCSEs including English and Maths",
|
||||
"type": "score",
|
||||
"category": "gcse",
|
||||
},
|
||||
"progress_8_score": {
|
||||
"name": "Progress 8",
|
||||
"short_name": "P8",
|
||||
"description": "Progress from KS2 baseline to GCSE relative to similar pupils nationally (0 = national average)",
|
||||
"type": "score",
|
||||
"category": "gcse",
|
||||
},
|
||||
"english_maths_standard_pass_pct": {
|
||||
"name": "English & Maths Grade 4+",
|
||||
"short_name": "E&M 4+",
|
||||
"description": "% of pupils achieving grade 4 (standard pass) or above in both English and Maths",
|
||||
"type": "percentage",
|
||||
"category": "gcse",
|
||||
},
|
||||
"english_maths_strong_pass_pct": {
|
||||
"name": "English & Maths Grade 5+",
|
||||
"short_name": "E&M 5+",
|
||||
"description": "% of pupils achieving grade 5 (strong pass) or above in both English and Maths",
|
||||
"type": "percentage",
|
||||
"category": "gcse",
|
||||
},
|
||||
"ebacc_entry_pct": {
|
||||
"name": "EBacc Entry %",
|
||||
"short_name": "EBacc Entry",
|
||||
"description": "% of pupils entered for the English Baccalaureate (English, Maths, Sciences, Languages, Humanities)",
|
||||
"type": "percentage",
|
||||
"category": "gcse",
|
||||
},
|
||||
"ebacc_standard_pass_pct": {
|
||||
"name": "EBacc Grade 4+",
|
||||
"short_name": "EBacc 4+",
|
||||
"description": "% of pupils achieving grade 4+ across all EBacc subjects",
|
||||
"type": "percentage",
|
||||
"category": "gcse",
|
||||
},
|
||||
"ebacc_strong_pass_pct": {
|
||||
"name": "EBacc Grade 5+",
|
||||
"short_name": "EBacc 5+",
|
||||
"description": "% of pupils achieving grade 5+ across all EBacc subjects",
|
||||
"type": "percentage",
|
||||
"category": "gcse",
|
||||
},
|
||||
"ebacc_avg_score": {
|
||||
"name": "EBacc Average Score",
|
||||
"short_name": "EBacc Avg",
|
||||
"description": "Average points score across EBacc subjects",
|
||||
"type": "score",
|
||||
"category": "gcse",
|
||||
},
|
||||
"gcse_grade_91_pct": {
|
||||
"name": "GCSE Grade 9–1 %",
|
||||
"short_name": "GCSE 9–1",
|
||||
"description": "% of GCSE entries achieving a grade 9 to 1",
|
||||
"type": "percentage",
|
||||
"category": "gcse",
|
||||
},
|
||||
}
|
||||
|
||||
# Ranking columns to include in rankings response
|
||||
@@ -456,6 +520,16 @@ RANKING_COLUMNS = [
|
||||
"rwm_expected_3yr_pct",
|
||||
"reading_avg_3yr",
|
||||
"maths_avg_3yr",
|
||||
# GCSE (KS4)
|
||||
"attainment_8_score",
|
||||
"progress_8_score",
|
||||
"english_maths_standard_pass_pct",
|
||||
"english_maths_strong_pass_pct",
|
||||
"ebacc_entry_pct",
|
||||
"ebacc_standard_pass_pct",
|
||||
"ebacc_strong_pass_pct",
|
||||
"ebacc_avg_score",
|
||||
"gcse_grade_91_pct",
|
||||
]
|
||||
|
||||
# School listing columns
|
||||
@@ -469,6 +543,10 @@ SCHOOL_COLUMNS = [
|
||||
"postcode",
|
||||
"religious_denomination",
|
||||
"age_range",
|
||||
"gender",
|
||||
"admissions_policy",
|
||||
"ofsted_grade",
|
||||
"ofsted_date",
|
||||
"latitude",
|
||||
"longitude",
|
||||
]
|
||||
|
||||
BIN
data/.DS_Store
vendored
BIN
data/.DS_Store
vendored
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
# Place your CSV data files here
|
||||
# Download from: https://www.compare-school-performance.service.gov.uk/download-data
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,24 +0,0 @@
|
||||
Field Number,Field Reference,Field Name,Values,Data Format,LA level field?,National level field?
|
||||
1,URN,School Unique Reference Number,999999,I6,No,No
|
||||
2,LA,LA number,999,I3,Yes,No
|
||||
3,ESTAB,ESTAB number,9999,I4,No,No
|
||||
4,SCHOOLTYPE,Type of school,String,,No,No
|
||||
5,NOR,Total number of pupils on roll,9999 or NA,,Yes,Yes
|
||||
6,NORG,Number of girls on roll,9999 or NA,,Yes,Yes
|
||||
7,NORB,Number of boys on roll,9999 or NA,,Yes,Yes
|
||||
8,PNORG,Percentage of girls on roll,99.9 or NA,,Yes,Yes
|
||||
9,PNORB,Percentage of boys on roll,99.9 or NA,,Yes,Yes
|
||||
10,TSENELSE,Number of eligible pupils with an EHC plan,9999 or NA,A4,Yes,Yes
|
||||
11,PSENELSE,Percentage of eligible pupils with an EHC plan,99.9 or NA,A4,Yes,Yes
|
||||
12,TSENELK,Number of eligible pupils with SEN support,9999 or NA,A4,Yes,Yes
|
||||
13,PSENELK,Percentage of eligible pupils with SEN support,99.9 or NA,A4,Yes,Yes
|
||||
14,NUMEAL,No. pupils where English not first language,9999 or NA,A4,Yes,Yes
|
||||
15,NUMENGFL,No. pupils with English first language,9999 or NA,A4,Yes,Yes
|
||||
16,NUMUNCFL,No. pupils where first language is unclassified,9999 or NA,A4,Yes,Yes
|
||||
17,PNUMEAL,% pupils where English not first language,99.9 or NA,A4,Yes,Yes
|
||||
18,PNUMENGFL,% pupils with English first language,99.9 or NA,A4,Yes,Yes
|
||||
19,PNUMUNCFL,% pupils where first language is unclassified,99.9 or NA,A4,Yes,Yes
|
||||
20,NUMFSM,No. pupils eligible for free school meals,9999 or NA,A4,Yes,Yes
|
||||
21,NUMFSMEVER,Number of pupils eligible for FSM at any time during the past 6 years,9999 or NA,A6,Yes,Yes
|
||||
22,NORFSMEVER,Total pupils for FSMEver,9999 or NA,,Yes,Yes
|
||||
23,PNUMFSMEVER,Percentage of pupils eligible for FSM at any time during the past 6 years,99.9 or NA,A4,Yes,Yes
|
||||
|
@@ -1,312 +0,0 @@
|
||||
Column,Field Name,Label/Description
|
||||
1,RECTYPE,Record type
|
||||
2,AlphaIND,Alphabetic index
|
||||
3,LEA,Local authority number
|
||||
4,ESTAB,Establishment number
|
||||
5,URN,School unique reference number
|
||||
6,SCHNAME,School/Local authority name
|
||||
7,ADDRESS1,School address (1)
|
||||
8,ADDRESS2,School address (2)
|
||||
9,ADDRESS3,School address (3)
|
||||
10,TOWN,School town
|
||||
11,PCODE,School postcode
|
||||
12,TELNUM,School telephone number
|
||||
13,PCON_CODE,School parliamentary constituency code
|
||||
14,PCON_NAME,School parliamentary constituency name
|
||||
15,URN_AC,Converter academy: URN
|
||||
16,SCHNAME_AC,Converter academy: name
|
||||
17,OPEN_AC,Converter academy: open date
|
||||
18,NFTYPE,School type
|
||||
19,ICLOSE,Closed Flag
|
||||
20,RELDENOM,Religious denomination
|
||||
21,AGERANGE,Age range
|
||||
22,TAB15,School published in secondary school (key stage 4) performance tables
|
||||
23,TAB1618,School published in school and college (key stage 5) performance tables
|
||||
24,TOTPUPS,Total number of pupils (including part-time pupils)
|
||||
25,TPUPYEAR,Number of pupils aged 11
|
||||
26,TELIG,Published eligible pupil number
|
||||
27,BELIG,Eligible boys on school roll at time of tests
|
||||
28,GELIG,Eligible girls on school roll at time of tests
|
||||
29,PBELIG,Percentage of eligible boys on school roll at time of tests
|
||||
30,PGELIG,Percentage of eligible girls on school roll at time of tests
|
||||
31,TKS1AVERAGE,Cohort level key stage 1 average points score [not populated in 2025]
|
||||
32,TKS1GROUP_L,Number of pupils in cohort with low KS1 attainment [not populated in 2025]
|
||||
33,PTKS1GROUP_L,Percentage of pupils in cohort with low KS1 attainment [not populated in 2025]
|
||||
34,TKS1GROUP_M,Number of pupils in cohort with medium KS1 attainment [not populated in 2025]
|
||||
35,PTKS1GROUP_M,Percentage of pupils in cohort with medium KS1 attainment [not populated in 2025]
|
||||
36,TKS1GROUP_H,Number of pupils in cohort high KS1 attainment [not populated in 2025]
|
||||
37,PTKS1GROUP_H,Percentage of pupils in cohort with high KS1 attainment [not populated in 2025]
|
||||
38,TKS1GROUP_NA,No. of pupils in KS1 group not calculable [not populated in 2025]
|
||||
39,PTKS1GROUP_NA,Percentage of pupils in KS1group not calculable [not populated in 2025]
|
||||
40,TFSM6CLA1A,Number of key stage 2 disadvantaged pupils (those who were eligible for free school meals in last 6 years or are looked after by the LA for a day or more or who have been adopted from care)
|
||||
41,PTFSM6CLA1A,Percentage of key stage 2 disadvantaged pupils
|
||||
42,TNotFSM6CLA1A,Number of key stage 2 pupils who are not disadvantaged
|
||||
43,PTNotFSM6CLA1A,Percentage of key stage 2 pupils who are not disadvantaged
|
||||
44,TEALGRP2,Number of eligible pupils with English as additional language (EAL)
|
||||
45,PTEALGRP2,Percentage of eligible pupils with English as additional language (EAL)
|
||||
46,TMOBN,Number of eligible pupils classified as non-mobile
|
||||
47,PTMOBN,Percentage of eligible pupils classified as non-mobile
|
||||
48,PTRWM_EXP,"Percentage of pupils reaching the expected standard in reading, writing and maths"
|
||||
49,PTRWM_HIGH,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
50,READPROG,Reading progress measure [not populated in 2025]
|
||||
51,READPROG_LOWER,Reading progress measure - lower confidence limit [not populated in 2025]
|
||||
52,READPROG_UPPER,Reading progress measure - upper confidence limit [not populated in 2025]
|
||||
53,READCOV,Reading progress measure - coverage [not populated in 2025]
|
||||
54,WRITPROG,Writing progress measure [not populated in 2025]
|
||||
55,WRITPROG_LOWER,Writing progress measure - lower confidence limit [not populated in 2025]
|
||||
56,WRITPROG_UPPER,Writing progress measure - upper confidence limit [not populated in 2025]
|
||||
57,WRITCOV,Writing progress measure - coverage [not populated in 2025]
|
||||
58,MATPROG,Maths progress measure [not populated in 2025]
|
||||
59,MATPROG_LOWER,Maths progress measure - lower confidence limit [not populated in 2025]
|
||||
60,MATPROG_UPPER,Maths progress measure - upper confidence limit [not populated in 2025]
|
||||
61,MATCOV,Maths progress measure - coverage [not populated in 2025]
|
||||
62,PTREAD_EXP,Percentage of pupils reaching the expected standard in reading
|
||||
63,PTREAD_HIGH,Percentage of pupils achieving a high score in reading
|
||||
64,PTREAD_AT,Percentage of pupils absent from or not able to access the test in reading
|
||||
65,READ_AVERAGE,Average scaled score in reading
|
||||
66,PTGPS_EXP,"Percentage of pupils reaching the expected standard in grammar, punctuation and spelling"
|
||||
67,PTGPS_HIGH,"Percentage of pupils achieving a high score in grammar, punctuation and spelling"
|
||||
68,PTGPS_AT,"Percentage of pupils absent from or not able to access the test in grammar, punctuation and spelling"
|
||||
69,GPS_AVERAGE,"Average scaled score in grammar, punctuation and spelling"
|
||||
70,PTMAT_EXP,Percentage of pupils reaching the expected standard in maths
|
||||
71,PTMAT_HIGH,Percentage of pupils achieving a high score in maths
|
||||
72,PTMAT_AT,Percentage of pupils absent from or not able to access the test in maths
|
||||
73,MAT_AVERAGE,Average scaled score in maths
|
||||
74,PTWRITTA_EXP,Percentage of pupils reaching the expected standard in writing
|
||||
75,PTWRITTA_HIGH,Percentage of pupils working at greater depth within the expected standard in writing
|
||||
76,PTWRITTA_WTS,Percentage of pupils working towards the expected standard in writing
|
||||
77,PTWRITTA_AD,Percentage of pupils absent or disapplied in writing TA
|
||||
78,PTSCITA_EXP,Percentage of pupils reaching the expected standard in science TA
|
||||
79,PTSCITA_AD,Percentage of pupils absent or disapplied in science TA
|
||||
80,PTRWM_EXP_B,"Percentage of boys reaching the expected standard in reading, writing and maths"
|
||||
81,PTRWM_EXP_G,"Percentage of girls reaching the expected standard in reading, writing and maths"
|
||||
82,PTRWM_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
|
||||
83,PTRWM_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
|
||||
84,PTRWM_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in reading, writing and maths [not populated in 2025]"
|
||||
85,PTRWM_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths"
|
||||
86,PTRWM_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths"
|
||||
87,DIFFN_RWM_EXP,"Difference between school percentage of disavantaged pupils and national percentage of other pupils reaching the expected standard in reading, writing and maths "
|
||||
88,PTRWM_EXP_EAL,"Percentage of EAL pupils reaching the expected standard in reading, writing and maths"
|
||||
89,PTRWM_EXP_MOBN,"Percentage of non-mobile pupils reaching the expected standard in reading, writing and maths"
|
||||
90,PTRWM_HIGH_B,Percentage of boys achieving a high score in reading and maths and working at greater depth in writing
|
||||
91,PTRWM_HIGH_G,"Percentage of girls reaching the HIGHected standard in reading, writing and maths"
|
||||
92,PTRWM_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
|
||||
93,PTRWM_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
|
||||
94,PTRWM_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading and maths and working at greater depth in writing [not populated in 2025]
|
||||
95,PTRWM_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
96,PTRWM_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
97,DIFFN_RWM_HIGH,"Difference between school percentage of disavantaged pupils and national percentage of other pupils achieving a high score in reading, writing and maths "
|
||||
98,PTRWM_HIGH_EAL,Percentage of EAL pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
99,PTRWM_HIGH_MOBN,Percentage of non-mobile pupils achieving a high score in reading and maths and working at greater depth in writing
|
||||
100,READPROG_B,Reading progress measure for boys [not populated in 2025]
|
||||
101,READPROG_B_LOWER,Reading progress measure for boys - lower confidence limit [not populated in 2025]
|
||||
102,READPROG_B_UPPER,Reading progress measure for boys - upper confidence limit [not populated in 2025]
|
||||
103,READPROG_G,Reading progress measure for girls [not populated in 2025]
|
||||
104,READPROG_G_LOWER,Reading progress measure for girls - lower confidence limit [not populated in 2025]
|
||||
105,READPROG_G_UPPER,Reading progress measure for girls - upper confidence limit [not populated in 2025]
|
||||
106,READPROG_L,Reading progress measure for pupils with low prior attainment [not populated in 2025]
|
||||
107,READPROG_L_LOWER,Reading progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
|
||||
108,READPROG_L_UPPER,Reading progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
|
||||
109,READPROG_M,Reading progress measure for pupils with medium prior attainment [not populated in 2025]
|
||||
110,READPROG_M_LOWER,Reading progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
|
||||
111,READPROG_M_UPPER,Reading progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
|
||||
112,READPROG_H,Reading progress measure for pupils with high prior attainment [not populated in 2025]
|
||||
113,READPROG_H_LOWER,Reading progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
|
||||
114,READPROG_H_UPPER,Reading progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
|
||||
115,READPROG_FSM6CLA1A,Reading progress measure for disadvantaged pupils [not populated in 2025]
|
||||
116,READPROG_FSM6CLA1A_LOWER,Reading progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
117,READPROG_FSM6CLA1A_UPPER,Reading progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
118,READPROG_NotFSM6CLA1A,Reading progress measure for non-disadvantaged pupils [not populated in 2025]
|
||||
119,READPROG_NotFSM6CLA1A_LOWER,Reading progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
120,READPROG_NotFSM6CLA1A_UPPER,Reading progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
121,DIFFN_READPROG,Difference between reading progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
|
||||
122,READPROG_EAL,Reading progress measure for EAL pupils [not populated in 2025]
|
||||
123,READPROG_EAL_LOWER,Reading progress measure for EAL pupils - lower confidence limit [not populated in 2025]
|
||||
124,READPROG_EAL_UPPER,Reading progress measure for EAL pupils - upper confidence limit [not populated in 2025]
|
||||
125,READPROG_MOBN,Reading progress measure for non-mobile pupils [not populated in 2025]
|
||||
126,READPROG_MOBN_LOWER,Reading progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
|
||||
127,READPROG_MOBN_UPPER,Reading progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
|
||||
128,WRITPROG_B,Writing progress measure for boys [not populated in 2025]
|
||||
129,WRITPROG_B_LOWER,Writing progress measure for boys - lower confidence limit [not populated in 2025]
|
||||
130,WRITPROG_B_UPPER,Writing progress measure for boys - upper confidence limit [not populated in 2025]
|
||||
131,WRITPROG_G,Writing progress measure for girls [not populated in 2025]
|
||||
132,WRITPROG_G_LOWER,Writing progress measure for girls - lower confidence limit [not populated in 2025]
|
||||
133,WRITPROG_G_UPPER,Writing progress measure for girls - upper confidence limit [not populated in 2025]
|
||||
134,WRITPROG_L,Writing progress measure for pupils with low prior attainment [not populated in 2025]
|
||||
135,WRITPROG_L_LOWER,Writing progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
|
||||
136,WRITPROG_L_UPPER,Writing progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
|
||||
137,WRITPROG_M,Writing progress measure for pupils with medium prior attainment [not populated in 2025]
|
||||
138,WRITPROG_M_LOWER,Writing progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
|
||||
139,WRITPROG_M_UPPER,Writing progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
|
||||
140,WRITPROG_H,Writing progress measure for pupils with high prior attainment [not populated in 2025]
|
||||
141,WRITPROG_H_LOWER,Writing progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
|
||||
142,WRITPROG_H_UPPER,Writing progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
|
||||
143,WRITPROG_FSM6CLA1A,Writing progress measure for disadvantaged pupils [not populated in 2025]
|
||||
144,WRITPROG_FSM6CLA1A_LOWER,Writing progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
145,WRITPROG_FSM6CLA1A_UPPER,Writing progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
146,WRITPROG_NotFSM6CLA1A,Writing progress measure for non-disadvantaged pupils [not populated in 2025]
|
||||
147,WRITPROG_NotFSM6CLA1A_LOWER,Writing progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
148,WRITPROG_NotFSM6CLA1A_UPPER,Writing progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
149,DIFFN_WRITPROG,Difference between writing progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
|
||||
150,WRITPROG_EAL,Writing progress measure for EAL pupils [not populated in 2025]
|
||||
151,WRITPROG_EAL_LOWER,Writing progress measure for EAL pupils - lower confidence limit [not populated in 2025]
|
||||
152,WRITPROG_EAL_UPPER,Writing progress measure for EAL pupils - upper confidence limit [not populated in 2025]
|
||||
153,WRITPROG_MOBN,Writing progress measure for non-mobile pupils [not populated in 2025]
|
||||
154,WRITPROG_MOBN_LOWER,Writing progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
|
||||
155,WRITPROG_MOBN_UPPER,Writing progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
|
||||
156,MATPROG_B,Maths progress measure for boys [not populated in 2025]
|
||||
157,MATPROG_B_LOWER,Maths progress measure for boys - lower confidence limit [not populated in 2025]
|
||||
158,MATPROG_B_UPPER,Maths progress measure for boys - upper confidence limit [not populated in 2025]
|
||||
159,MATPROG_G,Maths progress measure for girls [not populated in 2025]
|
||||
160,MATPROG_G_LOWER,Maths progress measure for girls - lower confidence limit [not populated in 2025]
|
||||
161,MATPROG_G_UPPER,Maths progress measure for girls - upper confidence limit [not populated in 2025]
|
||||
162,MATPROG_L,Maths progress measure for pupils with low prior attainment [not populated in 2025]
|
||||
163,MATPROG_L_LOWER,Maths progress measure for pupils with low prior attainment - lower confidence limit [not populated in 2025]
|
||||
164,MATPROG_L_UPPER,Maths progress measure for pupils with low prior attainment - upper confidence limit [not populated in 2025]
|
||||
165,MATPROG_M,Maths progress measure for pupils with medium prior attainment [not populated in 2025]
|
||||
166,MATPROG_M_LOWER,Maths progress measure for pupils with medium prior attainment - lower confidence limit [not populated in 2025]
|
||||
167,MATPROG_M_UPPER,Maths progress measure for pupils with medium prior attainment - upper confidence limit [not populated in 2025]
|
||||
168,MATPROG_H,Maths progress measure for pupils with high prior attainment [not populated in 2025]
|
||||
169,MATPROG_H_LOWER,Maths progress measure for pupils with high prior attainment - lower confidence limit [not populated in 2025]
|
||||
170,MATPROG_H_UPPER,Maths progress measure for pupils with high prior attainment - upper confidence limit [not populated in 2025]
|
||||
171,MATPROG_FSM6CLA1A,Maths progress measure for disadvantaged pupils [not populated in 2025]
|
||||
172,MATPROG_FSM6CLA1A_LOWER,Maths progress measure for disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
173,MATPROG_FSM6CLA1A_UPPER,Maths progress measure for disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
174,MATPROG_NotFSM6CLA1A,Maths progress measure for non-disadvantaged pupils [not populated in 2025]
|
||||
175,MATPROG_NotFSM6CLA1A_LOWER,Maths progress measure for non-disadvantaged pupils - lower confidence limit [not populated in 2025]
|
||||
176,MATPROG_NotFSM6CLA1A_UPPER,Maths progress measure for non-disadvantaged pupils - upper confidence limit [not populated in 2025]
|
||||
177,DIFFN_MATPROG,Difference between maths progress measure for disadvantaged pupils in school and other pupils nationally [not populated in 2025]
|
||||
178,MATPROG_EAL,Maths progress measure for EAL pupils [not populated in 2025]
|
||||
179,MATPROG_EAL_LOWER,Maths progress measure for EAL pupils - lower confidence limit [not populated in 2025]
|
||||
180,MATPROG_EAL_UPPER,Maths progress measure for EAL pupils - upper confidence limit [not populated in 2025]
|
||||
181,MATPROG_MOBN,Maths progress measure for non-mobile pupils [not populated in 2025]
|
||||
182,MATPROG_MOBN_LOWER,Maths progress measure for non-mobile pupils - lower confidence limit [not populated in 2025]
|
||||
183,MATPROG_MOBN_UPPER,Maths progress measure for non-mobile pupils - upper confidence limit [not populated in 2025]
|
||||
184,READ_AVERAGE_B,Average scaled score in reading for boys
|
||||
185,READ_AVERAGE_G,Average scaled score in reading for girls
|
||||
186,READ_AVERAGE_L,Average scaled score in reading for pupils with low prior attainment [not populated in 2025]
|
||||
187,READ_AVERAGE_M,Average scaled score in reading for pupils with medium prior attainment [not populated in 2025]
|
||||
188,READ_AVERAGE_H,Average scaled score in reading for pupils with high prior attainment [not populated in 2025]
|
||||
189,READ_AVERAGE_FSM6CLA1A,Average scaled score in reading for disadvantaged pupils
|
||||
190,READ_AVERAGE_NotFSM6CLA1A,Average scaled score in reading for non-disadvantaged pupils
|
||||
191,READ_AVERAGE_EAL,Average scaled score in reading for EAL pupils
|
||||
192,READ_AVERAGE_MOBN,Average scaled score in reading for MOBN pupils
|
||||
193,MAT_AVERAGE_B,Average scaled score in maths for boys
|
||||
194,MAT_AVERAGE_G,Average scaled score in maths for girls
|
||||
195,MAT_AVERAGE_L,Average scaled score in maths for pupils with low prior attainment [not populated in 2025]
|
||||
196,MAT_AVERAGE_M,Average scaled score in maths for pupils with medium prior attainment [not populated in 2025]
|
||||
197,MAT_AVERAGE_H,Average scaled score in maths for pupils with high prior attainment [not populated in 2025]
|
||||
198,MAT_AVERAGE_FSM6CLA1A,Average scaled score in maths for disadvantaged pupils
|
||||
199,MAT_AVERAGE_NotFSM6CLA1A,Average scaled score in maths for non-disadvantaged pupils
|
||||
200,MAT_AVERAGE_EAL,Average scaled score in maths for EAL pupils
|
||||
201,MAT_AVERAGE_MOBN,Average scaled score in maths for MOBN pupils
|
||||
202,GPS_AVERAGE_B,Average scaled score in GPS for boys
|
||||
203,GPS_AVERAGE_G,Average scaled score in GPS for girls
|
||||
204,GPS_AVERAGE_L,Average scaled score in GPS for pupils with low prior attainment [not populated in 2025]
|
||||
205,GPS_AVERAGE_M,Average scaled score in GPS for pupils with medium prior attainment [not populated in 2025]
|
||||
206,GPS_AVERAGE_H,Average scaled score in GPS for pupils with high prior attainment [not populated in 2025]
|
||||
207,GPS_AVERAGE_FSM6CLA1A,Average scaled score in GPS for disadvantaged pupils
|
||||
208,GPS_AVERAGE_NotFSM6CLA1A,Average scaled score in GPS for non-disadvantaged pupils
|
||||
209,GPS_AVERAGE_EAL,Average scaled score in GPS for EAL pupils
|
||||
210,GPS_AVERAGE_MOBN,Average scaled score in GPS for MOBN pupils
|
||||
211,PTREAD_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in reading [not populated in 2025]
|
||||
212,PTREAD_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in reading [not populated in 2025]
|
||||
213,PTREAD_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in reading [not populated in 2025]
|
||||
214,PTREAD_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in reading
|
||||
215,PTREAD_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in reading
|
||||
216,PTGPS_EXP_L,"Percentage of pupils with low prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
|
||||
217,PTGPS_EXP_M,"Percentage of pupils with medium prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
|
||||
218,PTGPS_EXP_H,"Percentage of pupils with high prior attainment reaching the expected standard in grammar, punctuation and spelling [not populated in 2025]"
|
||||
219,PTGPS_EXP_FSM6CLA1A,"Percentage of disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
|
||||
220,PTGPS_EXP_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils reaching the expected standard in grammar, punctuation and spelling"
|
||||
221,PTMAT_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in maths [not populated in 2025]
|
||||
222,PTMAT_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in maths [not populated in 2025]
|
||||
223,PTMAT_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in maths [not populated in 2025]
|
||||
224,PTMAT_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in maths
|
||||
225,PTMAT_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in maths
|
||||
226,PTWRITTA_EXP_L,Percentage of pupils with low prior attainment reaching the expected standard in writing [not populated in 2025]
|
||||
227,PTWRITTA_EXP_M,Percentage of pupils with medium prior attainment reaching the expected standard in writing [not populated in 2025]
|
||||
228,PTWRITTA_EXP_H,Percentage of pupils with high prior attainment reaching the expected standard in writing [not populated in 2025]
|
||||
229,PTWRITTA_EXP_FSM6CLA1A,Percentage of disadvantaged pupils reaching the expected standard in writing
|
||||
230,PTWRITTA_EXP_NotFSM6CLA1A,Percentage of non-disadvantaged pupils reaching the expected standard in writing
|
||||
231,PTREAD_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in reading [not populated in 2025]
|
||||
232,PTREAD_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in reading [not populated in 2025]
|
||||
233,PTREAD_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in reading [not populated in 2025]
|
||||
234,PTREAD_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in reading
|
||||
235,PTREAD_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in reading
|
||||
236,PTGPS_HIGH_L,"Percentage of pupils with low prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
|
||||
237,PTGPS_HIGH_M,"Percentage of pupils with medium prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
|
||||
238,PTGPS_HIGH_H,"Percentage of pupils with high prior attainment achieving a high score in grammar, punctuation and spelling [not populated in 2025]"
|
||||
239,PTGPS_HIGH_FSM6CLA1A,"Percentage of disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
|
||||
240,PTGPS_HIGH_NotFSM6CLA1A,"Percentage of non-disadvantaged pupils achieving a high score in grammar, punctuation and spelling"
|
||||
241,PTMAT_HIGH_L,Percentage of pupils with low prior attainment achieving a high score in maths [not populated in 2025]
|
||||
242,PTMAT_HIGH_M,Percentage of pupils with medium prior attainment achieving a high score in maths [not populated in 2025]
|
||||
243,PTMAT_HIGH_H,Percentage of pupils with high prior attainment achieving a high score in maths [not populated in 2025]
|
||||
244,PTMAT_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils achieving a high score in maths
|
||||
245,PTMAT_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils achieving a high score in maths
|
||||
246,PTWRITTA_HIGH_L,Percentage of pupils with low prior attainment working at greater depth in writing [not populated in 2025]
|
||||
247,PTWRITTA_HIGH_M,Percentage of pupils with medium prior attainment working at greater depth in writing [not populated in 2025]
|
||||
248,PTWRITTA_HIGH_H,Percentage of pupils with high prior attainment working at greater depth in writing [not populated in 2025]
|
||||
249,PTWRITTA_HIGH_FSM6CLA1A,Percentage of disadvantaged pupils working at greater depth in writing
|
||||
250,PTWRITTA_HIGH_NotFSM6CLA1A,Percentage of non-disadvantaged pupils working at greater depth in writing
|
||||
251,TEALGRP1,Number of eligible pupils with English as first language
|
||||
252,PTEALGRP1,Percentage of eligible pupils with English as first language
|
||||
253,TEALGRP3,Number of eligible pupils with unclassified language
|
||||
254,PTEALGRP3,Percentage of eligible pupils with unclassified language
|
||||
255,TSENELE,Number of eligible pupils with EHC plan
|
||||
256,PSENELE,Percentage of eligible pupils with EHC plan
|
||||
257,TSENELK,Number of eligible pupils with SEN support
|
||||
258,PSENELK,Percentage of eligible pupils with SEN support
|
||||
259,TSENELEK,Number of eligible pupils with SEN (EHC plan or SEN support)
|
||||
260,PSENELEK,Percentage of eligible pupils with SEN (EHC plan or SEN support)
|
||||
261,TELIG_24,Number of eligible pupils 2024
|
||||
262,PTFSM6CLA1A_24,Percentage of key stage 2 disadvantaged pupils one year prior
|
||||
263,PTNOTFSM6CLA1A_24,Percentage of key stage 2 pupils who are not disadvantaged one year prior
|
||||
264,PTRWM_EXP_24,"Percentage of pupils reaching the expected standard in reading, writing and maths one year prior"
|
||||
265,PTRWM_HIGH_24,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
|
||||
266,PTRWM_EXP_FSM6CLA1A_24,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
|
||||
267,PTRWM_HIGH_FSM6CLA1A_24,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
|
||||
268,PTRWM_EXP_NotFSM6CLA1A_24,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths one year prior"
|
||||
269,PTRWM_HIGH_NotFSM6CLA1A_24,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing one year prior
|
||||
270,READPROG_24,Reading progress measure - one year prior [not populated in 2025]
|
||||
271,READPROG_LOWER_24,Reading progress measure - lower confidence limit - one year prior [not populated in 2025]
|
||||
272,READPROG_UPPER_24,Reading progress measure - upper confidence limit - one year prior [not populated in 2025]
|
||||
273,WRITPROG_24,Writing progress measure - one year prior [not populated in 2025]
|
||||
274,WRITPROG_LOWER_24,Writing progress measure - lower confidence limit - one year prior [not populated in 2025]
|
||||
275,WRITPROG_UPPER_24,Writing progress measure - upper confidence limit - one year prior [not populated in 2025]
|
||||
276,MATPROG_24,Maths progress measure - one year prior [not populated in 2025]
|
||||
277,MATPROG_LOWER_24,Maths progress measure - lower confidence limit - one year prior [not populated in 2025]
|
||||
278,MATPROG_UPPER_24,Maths progress measure - upper confidence limit - one year prior [not populated in 2025]
|
||||
279,READ_AVERAGE_24,Average scaled score in reading - one year prior
|
||||
280,MAT_AVERAGE_24,Average scaled score in maths - one year prior
|
||||
281,TELIG_23,Number of eligible pupils 2023
|
||||
282,PTFSM6CLA1A_23,Percentage of key stage 2 disadvantaged pupils - two years prior
|
||||
283,PTNOTFSM6CLA1A_23,Percentage of key stage 2 pupils who are not disadvantaged - two years prior
|
||||
284,PTRWM_EXP_23,"Percentage of pupils reaching the expected standard in reading, writing and maths - two years prior"
|
||||
285,PTRWM_HIGH_23,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
|
||||
286,PTRWM_EXP_FSM6CLA1A_23,"Percentage of disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
|
||||
287,PTRWM_HIGH_FSM6CLA1A_23,Percentage of disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
|
||||
288,PTRWM_EXP_NotFSM6CLA1A_23,"Percentage of non-disadvantaged pupils reaching the expected standard in reading, writing and maths - two years prior"
|
||||
289,PTRWM_HIGH_NotFSM6CLA1A_23,Percentage of non-disadvantaged pupils achieving a high score in reading and maths and working at greater depth in writing - two years prior
|
||||
290,READPROG_23,Reading progress measure - two years prior
|
||||
291,READPROG_LOWER_23,Reading progress measure - lower confidence limit - two years prior
|
||||
292,READPROG_UPPER_23,Reading progress measure - upper confidence limit - two years prior
|
||||
293,WRITPROG_23,Writing progress measure - two years prior
|
||||
294,WRITPROG_LOWER_23,Writing progress measure - lower confidence limit - two years prior
|
||||
295,WRITPROG_UPPER_23,Writing progress measure - upper confidence limit - two years prior
|
||||
296,MATPROG_23,Maths progress measure - two years prior
|
||||
297,MATPROG_LOWER_23,Maths progress measure - lower confidence limit - two years prior
|
||||
298,MATPROG_UPPER_23,Maths progress measure - upper confidence limit - two years prior
|
||||
299,READ_AVERAGE_23,Average scaled score in reading - two years prior
|
||||
300,MAT_AVERAGE_23,Average scaled score in maths - two years prior
|
||||
301,TELIG_3YR,Total number of pupils at the end of Key Stage 2 over the past three years
|
||||
302,PTRWM_EXP_3YR,"Percentage of pupils reaching the expected standard in reading, writing and maths - 3 year total"
|
||||
303,PTRWM_HIGH_3YR,Percentage of pupils achieving a high score in reading and maths and working at greater depth in writing - 3 year total
|
||||
304,READ_AVERAGE_3YR,Average scaled score in reading - 3 year average
|
||||
305,MAT_AVERAGE_3YR,Average scaled score in maths - 3 year average
|
||||
306,READPROG_UNADJUSTED,Unadjusted reading progress measure [not populated in 2025]
|
||||
307,WRITPROG_UNADJUSTED,Unadjusted writing progress measure [not populated in 2025]
|
||||
308,MATPROG_UNADJUSTED,Unadjusted maths progress measure [not populated in 2025]
|
||||
309,READPROG_DESCR,Reading progress measure 'description' [not populated in 2025]
|
||||
310,WRITPROG_DESCR,Writing progress measure 'description' [not populated in 2025]
|
||||
311,MATPROG_DESCR,Maths progress measure 'description' [not populated in 2025]
|
||||
|
@@ -1,154 +0,0 @@
|
||||
LEA,LA Name,REGION,REGION NAME
|
||||
841,Darlington,1,North East A
|
||||
840,County Durham,1,North East A
|
||||
805,Hartlepool,1,North East A
|
||||
806,Middlesbrough,1,North East A
|
||||
807,Redcar and Cleveland,1,North East A
|
||||
808,Stockton-on-Tees,1,North East A
|
||||
390,Gateshead,3,North East B
|
||||
391,Newcastle upon Tyne,3,North East B
|
||||
392,North Tyneside,3,North East B
|
||||
929,Northumberland,3,North East B
|
||||
393,South Tyneside,3,North East B
|
||||
394,Sunderland,3,North East B
|
||||
889,Blackburn with Darwen,6,North West A
|
||||
890,Blackpool,6,North West A
|
||||
942,Cumberland,6,North West A
|
||||
943,Westmorland and Furness ,6,North West A
|
||||
888,Lancashire,6,North West A
|
||||
350,Bolton,7,North West B
|
||||
351,Bury,7,North West B
|
||||
352,Manchester,7,North West B
|
||||
353,Oldham,7,North West B
|
||||
354,Rochdale,7,North West B
|
||||
355,Salford,7,North West B
|
||||
356,Stockport,7,North West B
|
||||
357,Tameside,7,North West B
|
||||
358,Trafford,7,North West B
|
||||
359,Wigan,7,North West B
|
||||
895,Cheshire East,9,North West C
|
||||
896,Cheshire West and Chester,9,North West C
|
||||
876,Halton,9,North West C
|
||||
340,Knowsley,9,North West C
|
||||
341,Liverpool,9,North West C
|
||||
343,Sefton,9,North West C
|
||||
342,St. Helens,9,North West C
|
||||
877,Warrington,9,North West C
|
||||
344,Wirral,9,North West C
|
||||
811,East Riding of Yorkshire,10,North Yorkshire and The Humber
|
||||
810,"Kingston Upon Hull, City of",10,North Yorkshire and The Humber
|
||||
812,North East Lincolnshire,10,North Yorkshire and The Humber
|
||||
813,North Lincolnshire,10,North Yorkshire and The Humber
|
||||
815,North Yorkshire,10,North Yorkshire and The Humber
|
||||
816,York,10,North Yorkshire and The Humber
|
||||
370,Barnsley,12,South and West Yorkshire
|
||||
380,Bradford,12,South and West Yorkshire
|
||||
381,Calderdale,12,South and West Yorkshire
|
||||
371,Doncaster,12,South and West Yorkshire
|
||||
382,Kirklees,12,South and West Yorkshire
|
||||
383,Leeds,12,South and West Yorkshire
|
||||
372,Rotherham,12,South and West Yorkshire
|
||||
373,Sheffield,12,South and West Yorkshire
|
||||
384,Wakefield,12,South and West Yorkshire
|
||||
831,Derby,14,East Midlands A
|
||||
830,Derbyshire,14,East Midlands A
|
||||
892,Nottingham,14,East Midlands A
|
||||
891,Nottinghamshire,14,East Midlands A
|
||||
856,Leicester,16,East Midlands B
|
||||
855,Leicestershire,16,East Midlands B
|
||||
925,Lincolnshire,16,East Midlands B
|
||||
940,North Northamptonshire,16,East Midlands B
|
||||
941,West Northamptonshire,16,East Midlands B
|
||||
857,Rutland,16,East Midlands B
|
||||
893,Shropshire,20,West Midlands A
|
||||
860,Staffordshire,20,West Midlands A
|
||||
861,Stoke-on-Trent,20,West Midlands A
|
||||
894,Telford and Wrekin,20,West Midlands A
|
||||
884,"Herefordshire, County of",22,West Midlands B
|
||||
885,Worcestershire,22,West Midlands B
|
||||
330,Birmingham,24,West Midlands C
|
||||
331,Coventry,24,West Midlands C
|
||||
332,Dudley,24,West Midlands C
|
||||
333,Sandwell,24,West Midlands C
|
||||
334,Solihull,24,West Midlands C
|
||||
335,Walsall,24,West Midlands C
|
||||
937,Warwickshire,24,West Midlands C
|
||||
336,Wolverhampton,24,West Midlands C
|
||||
822,Bedford,25,East of England A
|
||||
873,Cambridgeshire,25,East of England A
|
||||
823,Central Bedfordshire,25,East of England A
|
||||
919,Hertfordshire,25,East of England A
|
||||
821,Luton,25,East of England A
|
||||
874,Peterborough,25,East of England A
|
||||
881,Essex,27,East of England B
|
||||
926,Norfolk,27,East of England B
|
||||
882,Southend-on-Sea,27,East of England B
|
||||
935,Suffolk,27,East of England B
|
||||
883,Thurrock,27,East of England B
|
||||
202,Camden,31,London Central
|
||||
206,Islington,31,London Central
|
||||
207,Kensington and Chelsea,31,London Central
|
||||
208,Lambeth,31,London Central
|
||||
210,Southwark,31,London Central
|
||||
212,Wandsworth,31,London Central
|
||||
213,Westminster,31,London Central
|
||||
301,Barking and Dagenham,32,London East
|
||||
303,Bexley,32,London East
|
||||
201,City of London,32,London East
|
||||
203,Greenwich,32,London East
|
||||
204,Hackney,32,London East
|
||||
311,Havering,32,London East
|
||||
209,Lewisham,32,London East
|
||||
316,Newham,32,London East
|
||||
317,Redbridge,32,London East
|
||||
211,Tower Hamlets,32,London East
|
||||
302,Barnet,33,London North
|
||||
308,Enfield,33,London North
|
||||
309,Haringey,33,London North
|
||||
320,Waltham Forest,33,London North
|
||||
305,Bromley,34,London South
|
||||
306,Croydon,34,London South
|
||||
314,Kingston upon Thames,34,London South
|
||||
315,Merton,34,London South
|
||||
318,Richmond upon Thames,34,London South
|
||||
319,Sutton,34,London South
|
||||
304,Brent,35,London West
|
||||
307,Ealing,35,London West
|
||||
205,Hammersmith and Fulham,35,London West
|
||||
310,Harrow,35,London West
|
||||
312,Hillingdon,35,London West
|
||||
313,Hounslow,35,London West
|
||||
867,Bracknell Forest,36,South East A
|
||||
825,Buckinghamshire,36,South East A
|
||||
826,Milton Keynes,36,South East A
|
||||
931,Oxfordshire,36,South East A
|
||||
870,Reading,36,South East A
|
||||
871,Slough,36,South East A
|
||||
869,West Berkshire,36,South East A
|
||||
868,Windsor and Maidenhead,36,South East A
|
||||
872,Wokingham,36,South East A
|
||||
850,Hampshire,37,South East B
|
||||
921,Isle of Wight,37,South East B
|
||||
851,Portsmouth,37,South East B
|
||||
852,Southampton,37,South East B
|
||||
936,Surrey,38,South East C
|
||||
938,West Sussex,38,South East C
|
||||
846,Brighton and Hove,39,South East D
|
||||
845,East Sussex,39,South East D
|
||||
886,Kent,39,South East D
|
||||
887,Medway,39,South East D
|
||||
839,"Bournemouth, Christchurch and Poole",43,South West A
|
||||
908,Cornwall,43,South West A
|
||||
878,Devon,43,South West A
|
||||
838,Dorset,43,South West A
|
||||
420,Isles of Scilly,43,South West A
|
||||
879,Plymouth,43,South West A
|
||||
933,Somerset,43,South West A
|
||||
880,Torbay,43,South West A
|
||||
800,Bath and North East Somerset,45,South West B
|
||||
801,"Bristol, City of",45,South West B
|
||||
916,Gloucestershire,45,South West B
|
||||
802,North Somerset,45,South West B
|
||||
803,South Gloucestershire,45,South West B
|
||||
866,Swindon,45,South West B
|
||||
865,Wiltshire,45,South West B
|
||||
|
@@ -8,8 +8,6 @@
|
||||
# TYPESENSE_API_KEY — Typesense admin API key
|
||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
||||
# KESTRA_USER — Kestra UI username (optional)
|
||||
# KESTRA_PASSWORD — Kestra UI password (optional)
|
||||
|
||||
services:
|
||||
|
||||
@@ -103,87 +101,6 @@ services:
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# ── Kestra — workflow orchestrator (legacy, kept during migration) ────
|
||||
kestra:
|
||||
image: kestra/kestra:latest
|
||||
container_name: schoolcompare_kestra
|
||||
command: server standalone
|
||||
ports:
|
||||
- "8090:8080"
|
||||
volumes:
|
||||
- kestra_storage:/app/storage
|
||||
environment:
|
||||
KESTRA_CONFIGURATION: |
|
||||
datasources:
|
||||
postgres:
|
||||
url: jdbc:postgresql://sc_database:5432/kestra
|
||||
driverClassName: org.postgresql.Driver
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
kestra:
|
||||
repository:
|
||||
type: postgres
|
||||
queue:
|
||||
type: postgres
|
||||
storage:
|
||||
type: local
|
||||
local:
|
||||
base-path: /app/storage
|
||||
depends_on:
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8081/health | grep -q '\"status\":\"UP\"'"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
|
||||
# ── Kestra init (legacy, kept during migration) ──────────────────────
|
||||
kestra-init:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-kestra-init:latest
|
||||
container_name: schoolcompare_kestra_init
|
||||
environment:
|
||||
KESTRA_URL: http://kestra:8080
|
||||
KESTRA_USER: ${KESTRA_USER:-}
|
||||
KESTRA_PASSWORD: ${KESTRA_PASSWORD:-}
|
||||
depends_on:
|
||||
kestra:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend
|
||||
restart: "no"
|
||||
|
||||
# ── Data integrator (legacy, kept during migration) ──────────────────
|
||||
integrator:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-integrator:latest
|
||||
container_name: schoolcompare_integrator
|
||||
ports:
|
||||
- "8001:8001"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||
DATA_DIR: /data
|
||||
BACKEND_URL: http://backend:80
|
||||
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
||||
PYTHONUNBUFFERED: 1
|
||||
volumes:
|
||||
- supplementary_data:/data
|
||||
depends_on:
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# ── Airflow API Server + UI ───────────────────────────────────────────
|
||||
airflow-api-server:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
|
||||
@@ -282,7 +199,5 @@ networks:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
kestra_storage:
|
||||
supplementary_data:
|
||||
typesense_data:
|
||||
airflow_logs:
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
POSTGRES_USER: schoolcompare
|
||||
POSTGRES_PASSWORD: schoolcompare
|
||||
POSTGRES_DB: schoolcompare
|
||||
POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
@@ -119,6 +120,8 @@ services:
|
||||
PG_DATABASE: schoolcompare
|
||||
TYPESENSE_URL: http://typesense:8108
|
||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||
BACKEND_URL: http://backend:80
|
||||
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
||||
volumes:
|
||||
|
||||
depends_on:
|
||||
|
||||
2530
frontend/app.js
2530
frontend/app.js
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="8" fill="#1a1612"/>
|
||||
<circle cx="20" cy="20" r="14" stroke="#e07256" stroke-width="2"/>
|
||||
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="#e07256" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="20" r="3" fill="#e07256"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 374 B |
@@ -1,663 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SchoolCompare | Compare Primary School Performance</title>
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="description"
|
||||
content="Compare primary school KS2 performance across England. Search, filter and compare Reading, Writing and Maths results for thousands of schools."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="school comparison, KS2 results, primary school performance, England schools, SATs results"
|
||||
/>
|
||||
<meta name="author" content="SchoolCompare" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- Analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.schoolcompare.co.uk/script.js"
|
||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||
></script>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Canonical -->
|
||||
<link rel="canonical" href="https://schoolcompare.co.uk/" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://schoolcompare.co.uk/" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="SchoolCompare | Compare Primary School Performance"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Compare primary school KS2 performance across England. Search and compare Reading, Writing and Maths results."
|
||||
/>
|
||||
<meta property="og:site_name" content="SchoolCompare" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:url" content="https://schoolcompare.co.uk/" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="SchoolCompare | Compare Primary School Performance"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Compare primary school KS2 performance across England."
|
||||
/>
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "SchoolCompare",
|
||||
"url": "https://schoolcompare.co.uk",
|
||||
"description": "Compare primary school KS2 performance across England",
|
||||
"applicationCategory": "EducationalApplication",
|
||||
"operatingSystem": "Web",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "GBP"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "SchoolCompare",
|
||||
"url": "https://schoolcompare.co.uk"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<!-- Leaflet Map Library -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""
|
||||
/>
|
||||
<script
|
||||
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""
|
||||
></script>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="noise-overlay"></div>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<a href="/" class="logo">
|
||||
<div class="logo-icon">
|
||||
<svg
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="20" cy="20" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-title">SchoolCompare</span>
|
||||
<span class="logo-subtitle">schoolcompare.co.uk</span>
|
||||
</div>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="/" class="nav-link active" data-view="home"
|
||||
>Home</a
|
||||
>
|
||||
<a href="/compare" class="nav-link" data-view="compare"
|
||||
>Compare</a
|
||||
>
|
||||
<a href="/rankings" class="nav-link" data-view="rankings"
|
||||
>Rankings</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<!-- Home View -->
|
||||
<section id="home-view" class="view active">
|
||||
<div class="hero">
|
||||
<h1 class="hero-title">
|
||||
Compare Primary School Performance
|
||||
</h1>
|
||||
<p class="hero-subtitle">
|
||||
Search and compare KS2 results across England's primary
|
||||
schools
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="search-section">
|
||||
<div class="search-mode-toggle">
|
||||
<button class="search-mode-btn active" data-mode="name">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
Find by Name
|
||||
</button>
|
||||
<button class="search-mode-btn" data-mode="location">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
Find by Location
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="name-search-panel" class="search-panel active">
|
||||
<div class="search-container">
|
||||
<input
|
||||
type="text"
|
||||
id="school-search"
|
||||
class="search-input"
|
||||
placeholder="Search primary schools by name..."
|
||||
/>
|
||||
<div class="search-icon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select
|
||||
id="local-authority-filter"
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="">All Areas</option>
|
||||
</select>
|
||||
<select id="type-filter" class="filter-select">
|
||||
<option value="">All School Types</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="location-search-panel" class="search-panel">
|
||||
<div class="location-input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="postcode-search"
|
||||
class="search-input postcode-input"
|
||||
placeholder="Enter postcode..."
|
||||
/>
|
||||
<select
|
||||
id="radius-select"
|
||||
class="filter-select radius-select"
|
||||
>
|
||||
<option value="0.5" selected>1/2 mile</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="2">2 miles</option>
|
||||
</select>
|
||||
<select
|
||||
id="type-filter-location"
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
</select>
|
||||
<button
|
||||
id="location-search-btn"
|
||||
class="btn btn-primary location-btn"
|
||||
>
|
||||
Find Nearby
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view-toggle" id="view-toggle" style="display: none">
|
||||
<button class="view-toggle-btn active" data-view="list">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
List
|
||||
</button>
|
||||
<button class="view-toggle-btn" data-view="map">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
Map
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="results-container" id="results-container">
|
||||
<div class="results-map" id="results-map"></div>
|
||||
<div class="schools-grid" id="schools-grid">
|
||||
<!-- School cards populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Compare View -->
|
||||
<section id="compare-view" class="view">
|
||||
<div class="compare-header">
|
||||
<h2 class="section-title">Compare Primary Schools</h2>
|
||||
<p class="section-subtitle">
|
||||
Select schools to compare their KS2 performance over
|
||||
time
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="compare-search-section">
|
||||
<input
|
||||
type="text"
|
||||
id="compare-search"
|
||||
class="search-input"
|
||||
placeholder="Add a school to compare..."
|
||||
/>
|
||||
<div id="compare-results" class="compare-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="selected-schools" id="selected-schools">
|
||||
<div class="empty-selection">
|
||||
<div class="empty-icon">
|
||||
<svg
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<rect
|
||||
x="6"
|
||||
y="10"
|
||||
width="36"
|
||||
height="28"
|
||||
rx="2"
|
||||
/>
|
||||
<path d="M6 18h36" />
|
||||
<circle
|
||||
cx="14"
|
||||
cy="14"
|
||||
r="2"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="14"
|
||||
r="2"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>Search and add schools to compare</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="charts-section"
|
||||
id="charts-section"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="metric-selector">
|
||||
<label>Select KS2 Metric:</label>
|
||||
<select id="metric-select" class="filter-select">
|
||||
<optgroup label="Expected Standard">
|
||||
<option value="rwm_expected_pct">
|
||||
Reading, Writing & Maths Combined %
|
||||
</option>
|
||||
<option value="reading_expected_pct">
|
||||
Reading Expected %
|
||||
</option>
|
||||
<option value="writing_expected_pct">
|
||||
Writing Expected %
|
||||
</option>
|
||||
<option value="maths_expected_pct">
|
||||
Maths Expected %
|
||||
</option>
|
||||
<option value="gps_expected_pct">
|
||||
GPS Expected %
|
||||
</option>
|
||||
<option value="science_expected_pct">
|
||||
Science Expected %
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
<option value="rwm_high_pct">
|
||||
RWM Combined Higher %
|
||||
</option>
|
||||
<option value="reading_high_pct">
|
||||
Reading Higher %
|
||||
</option>
|
||||
<option value="writing_high_pct">
|
||||
Writing Higher %
|
||||
</option>
|
||||
<option value="maths_high_pct">
|
||||
Maths Higher %
|
||||
</option>
|
||||
<option value="gps_high_pct">
|
||||
GPS Higher %
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
<option value="reading_progress">
|
||||
Reading Progress
|
||||
</option>
|
||||
<option value="writing_progress">
|
||||
Writing Progress
|
||||
</option>
|
||||
<option value="maths_progress">
|
||||
Maths Progress
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
<option value="reading_avg_score">
|
||||
Reading Avg Score
|
||||
</option>
|
||||
<option value="maths_avg_score">
|
||||
Maths Avg Score
|
||||
</option>
|
||||
<option value="gps_avg_score">
|
||||
GPS Avg Score
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
<option value="rwm_expected_boys_pct">
|
||||
RWM Expected % (Boys)
|
||||
</option>
|
||||
<option value="rwm_expected_girls_pct">
|
||||
RWM Expected % (Girls)
|
||||
</option>
|
||||
<option value="rwm_high_boys_pct">
|
||||
RWM Higher % (Boys)
|
||||
</option>
|
||||
<option value="rwm_high_girls_pct">
|
||||
RWM Higher % (Girls)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
<option value="rwm_expected_disadvantaged_pct">
|
||||
RWM Expected % (Disadvantaged)
|
||||
</option>
|
||||
<option
|
||||
value="rwm_expected_non_disadvantaged_pct"
|
||||
>
|
||||
RWM Expected % (Non-Disadvantaged)
|
||||
</option>
|
||||
<option value="disadvantaged_gap">
|
||||
Disadvantaged Gap vs National
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="School Context">
|
||||
<option value="disadvantaged_pct">
|
||||
% Disadvantaged Pupils
|
||||
</option>
|
||||
<option value="eal_pct">% EAL Pupils</option>
|
||||
<option value="sen_support_pct">
|
||||
% SEN Support
|
||||
</option>
|
||||
<option value="stability_pct">
|
||||
% Pupil Stability
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
<option value="rwm_expected_3yr_pct">
|
||||
RWM Expected % (3-Year Avg)
|
||||
</option>
|
||||
<option value="reading_avg_3yr">
|
||||
Reading Score (3-Year Avg)
|
||||
</option>
|
||||
<option value="maths_avg_3yr">
|
||||
Maths Score (3-Year Avg)
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<canvas id="comparison-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="data-table-container">
|
||||
<table class="data-table" id="comparison-table">
|
||||
<thead>
|
||||
<tr id="table-header"></tr>
|
||||
</thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rankings View -->
|
||||
<section id="rankings-view" class="view">
|
||||
<div class="rankings-header">
|
||||
<h2 class="section-title">Primary School Rankings</h2>
|
||||
<p class="section-subtitle">
|
||||
Top performing primary schools ranked by KS2 metric
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rankings-controls">
|
||||
<select id="ranking-area" class="filter-select">
|
||||
<option value="">All Areas</option>
|
||||
<!-- Populated by JS -->
|
||||
</select>
|
||||
<select id="ranking-metric" class="filter-select">
|
||||
<optgroup label="Expected Standard">
|
||||
<option value="rwm_expected_pct">
|
||||
Reading, Writing & Maths Combined %
|
||||
</option>
|
||||
<option value="reading_expected_pct">
|
||||
Reading Expected %
|
||||
</option>
|
||||
<option value="writing_expected_pct">
|
||||
Writing Expected %
|
||||
</option>
|
||||
<option value="maths_expected_pct">
|
||||
Maths Expected %
|
||||
</option>
|
||||
<option value="gps_expected_pct">
|
||||
GPS Expected %
|
||||
</option>
|
||||
<option value="science_expected_pct">
|
||||
Science Expected %
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
<option value="rwm_high_pct">
|
||||
RWM Combined Higher %
|
||||
</option>
|
||||
<option value="reading_high_pct">
|
||||
Reading Higher %
|
||||
</option>
|
||||
<option value="writing_high_pct">
|
||||
Writing Higher %
|
||||
</option>
|
||||
<option value="maths_high_pct">
|
||||
Maths Higher %
|
||||
</option>
|
||||
<option value="gps_high_pct">GPS Higher %</option>
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
<option value="reading_progress">
|
||||
Reading Progress
|
||||
</option>
|
||||
<option value="writing_progress">
|
||||
Writing Progress
|
||||
</option>
|
||||
<option value="maths_progress">
|
||||
Maths Progress
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
<option value="reading_avg_score">
|
||||
Reading Avg Score
|
||||
</option>
|
||||
<option value="maths_avg_score">
|
||||
Maths Avg Score
|
||||
</option>
|
||||
<option value="gps_avg_score">GPS Avg Score</option>
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
<option value="rwm_expected_boys_pct">
|
||||
RWM Expected % (Boys)
|
||||
</option>
|
||||
<option value="rwm_expected_girls_pct">
|
||||
RWM Expected % (Girls)
|
||||
</option>
|
||||
<option value="rwm_high_boys_pct">
|
||||
RWM Higher % (Boys)
|
||||
</option>
|
||||
<option value="rwm_high_girls_pct">
|
||||
RWM Higher % (Girls)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
<option value="rwm_expected_disadvantaged_pct">
|
||||
RWM Expected % (Disadvantaged)
|
||||
</option>
|
||||
<option value="rwm_expected_non_disadvantaged_pct">
|
||||
RWM Expected % (Non-Disadvantaged)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
<option value="rwm_expected_3yr_pct">
|
||||
RWM Expected % (3-Year Avg)
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<select id="ranking-year" class="filter-select">
|
||||
<!-- Populated by JS -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="rankings-list" id="rankings-list">
|
||||
<!-- Rankings populated by JS -->
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- School Detail Modal -->
|
||||
<div class="modal" id="school-modal">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" id="modal-close">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="modal-header">
|
||||
<button
|
||||
class="btn btn-primary modal-compare-btn"
|
||||
id="add-to-compare"
|
||||
>
|
||||
Add to Compare
|
||||
</button>
|
||||
<h2 id="modal-school-name"></h2>
|
||||
<div class="modal-meta" id="modal-meta"></div>
|
||||
<div class="modal-details" id="modal-details"></div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-chart-container">
|
||||
<canvas id="school-detail-chart"></canvas>
|
||||
</div>
|
||||
<div class="modal-stats" id="modal-stats"></div>
|
||||
<div class="modal-map-container" id="modal-map-container">
|
||||
<h4>Location</h4>
|
||||
<div class="modal-map" id="modal-map"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<div class="footer-contact">
|
||||
<a href="mailto:contact@schoolcompare.co.uk">Contact Us</a>
|
||||
</div>
|
||||
<div class="footer-source">
|
||||
<p>
|
||||
Data source:
|
||||
<a
|
||||
href="https://www.compare-school-performance.service.gov.uk/"
|
||||
target="_blank"
|
||||
>UK Government - Compare School Performance</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,8 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /compare
|
||||
Allow: /rankings
|
||||
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: https://schoolcompare.co.uk/sitemap.xml
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://schoolcompare.co.uk/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://schoolcompare.co.uk/compare</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://schoolcompare.co.uk/rankings</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
1903
frontend/styles.css
1903
frontend/styles.css
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY scripts/ ./scripts/
|
||||
COPY server.py .
|
||||
|
||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -1,6 +0,0 @@
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache curl
|
||||
COPY flows/ /flows/
|
||||
COPY docker/kestra-init.sh /kestra-init.sh
|
||||
RUN chmod +x /kestra-init.sh
|
||||
CMD ["/kestra-init.sh"]
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
KESTRA_URL="${KESTRA_URL:-http://kestra:8080}"
|
||||
MAX_WAIT=120
|
||||
|
||||
# Basic auth — set KESTRA_USER / KESTRA_PASSWORD if authentication is enabled
|
||||
AUTH=""
|
||||
if [ -n "$KESTRA_USER" ] && [ -n "$KESTRA_PASSWORD" ]; then
|
||||
AUTH="-u ${KESTRA_USER}:${KESTRA_PASSWORD}"
|
||||
fi
|
||||
|
||||
echo "Waiting for Kestra API at ${KESTRA_URL}..."
|
||||
elapsed=0
|
||||
until curl -sf $AUTH "${KESTRA_URL}/api/v1/flows/search" > /dev/null 2>&1; do
|
||||
if [ "$elapsed" -ge "$MAX_WAIT" ]; then
|
||||
echo "ERROR: Kestra API not reachable after ${MAX_WAIT}s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
echo "Kestra API is ready."
|
||||
|
||||
echo "Importing flows..."
|
||||
|
||||
for f in /flows/*.yml; do
|
||||
name="$(basename "$f")"
|
||||
echo " -> $name"
|
||||
|
||||
http_code=$(curl -s $AUTH -o /tmp/kestra_resp -w "%{http_code}" \
|
||||
-X POST "${KESTRA_URL}/api/v1/flows" \
|
||||
-H "Content-Type: application/x-yaml" \
|
||||
--data-binary "@${f}")
|
||||
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
||||
echo " created"
|
||||
elif [ "$http_code" = "409" ]; then
|
||||
ns=$(grep '^namespace:' "$f" | awk '{print $2}')
|
||||
id=$(grep '^id:' "$f" | awk '{print $2}')
|
||||
http_code2=$(curl -s $AUTH -o /tmp/kestra_resp -w "%{http_code}" \
|
||||
-X PUT "${KESTRA_URL}/api/v1/flows/${ns}/${id}" \
|
||||
-H "Content-Type: application/x-yaml" \
|
||||
--data-binary "@${f}")
|
||||
if [ "$http_code2" = "200" ] || [ "$http_code2" = "201" ]; then
|
||||
echo " updated"
|
||||
else
|
||||
echo " ERROR updating $name: HTTP $http_code2"
|
||||
cat /tmp/kestra_resp; echo
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo " ERROR importing $name: HTTP $http_code"
|
||||
cat /tmp/kestra_resp; echo
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All flows imported."
|
||||
@@ -1,26 +0,0 @@
|
||||
id: admissions-annual-update
|
||||
namespace: schoolcompare.data
|
||||
description: Download and load school admissions data via EES API
|
||||
|
||||
triggers:
|
||||
- id: annual-schedule
|
||||
type: io.kestra.plugin.core.trigger.Schedule
|
||||
cron: "0 4 1 7 *" # 1 July annually at 04:00
|
||||
|
||||
tasks:
|
||||
- id: download
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/admissions?action=download
|
||||
method: POST
|
||||
timeout: PT20M
|
||||
|
||||
- id: load
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/admissions?action=load
|
||||
method: POST
|
||||
timeout: PT30M
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 3
|
||||
interval: PT15M
|
||||
@@ -1,26 +0,0 @@
|
||||
id: census-annual-update
|
||||
namespace: schoolcompare.data
|
||||
description: Download and load School Census (SPC) data via EES API
|
||||
|
||||
triggers:
|
||||
- id: annual-schedule
|
||||
type: io.kestra.plugin.core.trigger.Schedule
|
||||
cron: "0 4 1 9 *" # 1 September annually at 04:00
|
||||
|
||||
tasks:
|
||||
- id: download
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/census?action=download
|
||||
method: POST
|
||||
timeout: PT20M
|
||||
|
||||
- id: load
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/census?action=load
|
||||
method: POST
|
||||
timeout: PT30M
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 3
|
||||
interval: PT15M
|
||||
@@ -1,26 +0,0 @@
|
||||
id: finance-annual-update
|
||||
namespace: schoolcompare.data
|
||||
description: Fetch FBIT financial benchmarking data from DfE API for all schools
|
||||
|
||||
triggers:
|
||||
- id: annual-schedule
|
||||
type: io.kestra.plugin.core.trigger.Schedule
|
||||
cron: "0 4 1 12 *" # 1 December annually at 04:00
|
||||
|
||||
tasks:
|
||||
- id: download
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/finance?action=download
|
||||
method: POST
|
||||
timeout: PT120M # Fetches per-school from API — ~20k schools
|
||||
|
||||
- id: load
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/finance?action=load
|
||||
method: POST
|
||||
timeout: PT30M
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 2
|
||||
interval: PT30M
|
||||
@@ -1,31 +0,0 @@
|
||||
id: gias-weekly-update
|
||||
namespace: schoolcompare.data
|
||||
description: Download and load GIAS (Get Information About Schools) bulk CSV
|
||||
|
||||
triggers:
|
||||
- id: weekly-schedule
|
||||
type: io.kestra.plugin.core.trigger.Schedule
|
||||
cron: "0 3 * * 0" # Every Sunday at 03:00
|
||||
|
||||
tasks:
|
||||
- id: download
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/gias?action=download
|
||||
method: POST
|
||||
timeout: PT30M
|
||||
|
||||
- id: load
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/gias?action=load
|
||||
method: POST
|
||||
timeout: PT30M
|
||||
|
||||
errors:
|
||||
- id: notify-failure
|
||||
type: io.kestra.plugin.core.log.Log
|
||||
message: "GIAS update FAILED: {{ error.message }}"
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 3
|
||||
interval: PT10M
|
||||
@@ -1,26 +0,0 @@
|
||||
id: idaci-annual-check
|
||||
namespace: schoolcompare.data
|
||||
description: Download IoD2019 IDACI file and compute deprivation scores for all schools
|
||||
|
||||
triggers:
|
||||
- id: annual-schedule
|
||||
type: io.kestra.plugin.core.trigger.Schedule
|
||||
cron: "0 5 1 1 *" # 1 January annually at 05:00
|
||||
|
||||
tasks:
|
||||
- id: download
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/idaci?action=download
|
||||
method: POST
|
||||
timeout: PT10M
|
||||
|
||||
- id: load
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/idaci?action=load
|
||||
method: POST
|
||||
timeout: PT60M
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 2
|
||||
interval: PT30M
|
||||
@@ -1,23 +0,0 @@
|
||||
id: ks2-reimport
|
||||
namespace: schoolcompare.data
|
||||
description: Re-import KS2 attainment data from bundled CSV files (use after DB wipe)
|
||||
|
||||
# No scheduled trigger — run manually from the Kestra UI when needed.
|
||||
|
||||
tasks:
|
||||
- id: reimport
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/ks2?action=load
|
||||
method: POST
|
||||
allowFailed: false
|
||||
timeout: PT30S # fire-and-forget; backend runs migration in background
|
||||
|
||||
errors:
|
||||
- id: notify-failure
|
||||
type: io.kestra.plugin.core.log.Log
|
||||
message: "KS2 re-import FAILED: {{ error.message }}"
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 2
|
||||
interval: PT5M
|
||||
@@ -1,33 +0,0 @@
|
||||
id: ofsted-monthly-update
|
||||
namespace: schoolcompare.data
|
||||
description: Download and load Ofsted Monthly Management Information CSV
|
||||
|
||||
triggers:
|
||||
- id: monthly-schedule
|
||||
type: io.kestra.plugin.core.trigger.Schedule
|
||||
cron: "0 2 1 * *" # 1st of each month at 02:00
|
||||
|
||||
tasks:
|
||||
- id: download
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/ofsted?action=download
|
||||
method: POST
|
||||
allowFailed: false
|
||||
timeout: PT10M
|
||||
|
||||
- id: load
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/ofsted?action=load
|
||||
method: POST
|
||||
allowFailed: false
|
||||
timeout: PT30M
|
||||
|
||||
errors:
|
||||
- id: notify-failure
|
||||
type: io.kestra.plugin.core.log.Log
|
||||
message: "Ofsted update FAILED: {{ error.message }}"
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 3
|
||||
interval: PT10M
|
||||
@@ -1,31 +0,0 @@
|
||||
id: parent-view-monthly-check
|
||||
namespace: schoolcompare.data
|
||||
description: Download and load Ofsted Parent View open data (released ~3x/year)
|
||||
|
||||
triggers:
|
||||
- id: monthly-schedule
|
||||
type: io.kestra.plugin.core.trigger.Schedule
|
||||
cron: "0 3 1 * *" # 1st of each month at 03:00
|
||||
|
||||
tasks:
|
||||
- id: download
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/parent_view?action=download
|
||||
method: POST
|
||||
timeout: PT10M
|
||||
|
||||
- id: load
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/parent_view?action=load
|
||||
method: POST
|
||||
timeout: PT20M
|
||||
|
||||
errors:
|
||||
- id: notify-failure
|
||||
type: io.kestra.plugin.core.log.Log
|
||||
message: "Parent View update FAILED: {{ error.message }}"
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 3
|
||||
interval: PT10M
|
||||
@@ -1,26 +0,0 @@
|
||||
id: phonics-annual-update
|
||||
namespace: schoolcompare.data
|
||||
description: Download and load Phonics Screening Check data via EES API
|
||||
|
||||
triggers:
|
||||
- id: annual-schedule
|
||||
type: io.kestra.plugin.core.trigger.Schedule
|
||||
cron: "0 5 1 9 *" # 1 September annually at 05:00
|
||||
|
||||
tasks:
|
||||
- id: download
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/phonics?action=download
|
||||
method: POST
|
||||
timeout: PT20M
|
||||
|
||||
- id: load
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/phonics?action=load
|
||||
method: POST
|
||||
timeout: PT30M
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 3
|
||||
interval: PT15M
|
||||
@@ -1,26 +0,0 @@
|
||||
id: sen-detail-annual-update
|
||||
namespace: schoolcompare.data
|
||||
description: Download and load SEN primary need breakdown via EES API
|
||||
|
||||
triggers:
|
||||
- id: annual-schedule
|
||||
type: io.kestra.plugin.core.trigger.Schedule
|
||||
cron: "0 4 15 9 *" # 15 September annually at 04:00
|
||||
|
||||
tasks:
|
||||
- id: download
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/sen_detail?action=download
|
||||
method: POST
|
||||
timeout: PT20M
|
||||
|
||||
- id: load
|
||||
type: io.kestra.plugin.core.http.Request
|
||||
uri: http://integrator:8001/run/sen_detail?action=load
|
||||
method: POST
|
||||
timeout: PT30M
|
||||
|
||||
retry:
|
||||
type: constant
|
||||
maxAttempts: 3
|
||||
interval: PT15M
|
||||
@@ -1,7 +0,0 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
requests==2.32.3
|
||||
pandas==2.2.3
|
||||
openpyxl==3.1.5
|
||||
psycopg2-binary==2.9.9
|
||||
sqlalchemy==2.0.35
|
||||
@@ -1,14 +0,0 @@
|
||||
"""Configuration for the data integrator."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://schoolcompare:schoolcompare@db:5432/schoolcompare",
|
||||
)
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
|
||||
SUPPLEMENTARY_DIR = DATA_DIR / "supplementary"
|
||||
|
||||
BACKEND_URL = os.environ.get("BACKEND_URL", "http://backend:80")
|
||||
ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "changeme")
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Database connection for the integrator."""
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config import DATABASE_URL
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_session():
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1,184 +0,0 @@
|
||||
"""
|
||||
School Admissions data downloader and loader.
|
||||
|
||||
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
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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 download_release_zip_csv
|
||||
|
||||
DEST_DIR = SUPPLEMENTARY_DIR / "admissions"
|
||||
PUBLICATION_SLUG = "primary-and-secondary-school-applications-and-offers"
|
||||
|
||||
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", "Z", ""}
|
||||
|
||||
# Maps actual CSV column names → internal field names
|
||||
COLUMN_MAP = {
|
||||
# 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)
|
||||
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:
|
||||
if pd.isna(val):
|
||||
return None
|
||||
s = str(val).strip().upper().replace(",", "")
|
||||
if s in NULL_VALUES:
|
||||
return None
|
||||
try:
|
||||
return int(float(s))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_pct(val) -> float | None:
|
||||
if pd.isna(val):
|
||||
return None
|
||||
s = str(val).strip().upper().replace("%", "")
|
||||
if s in NULL_VALUES:
|
||||
return None
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
||||
if path is None:
|
||||
dest = (data_dir / "supplementary" / "admissions") if data_dir else DEST_DIR
|
||||
files = sorted(dest.glob("*.csv"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No admissions CSV found in {dest}")
|
||||
path = files[-1]
|
||||
|
||||
print(f" Admissions: loading {path} ...")
|
||||
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)
|
||||
|
||||
# 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"])
|
||||
year = int(row["year"])
|
||||
|
||||
pan = _parse_int(row.get("pan"))
|
||||
total_apps = _parse_int(row.get("total_applications"))
|
||||
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(
|
||||
text("""
|
||||
INSERT INTO school_admissions
|
||||
(urn, year, published_admission_number, total_applications,
|
||||
first_preference_offers_pct, oversubscribed)
|
||||
VALUES (:urn, :year, :pan, :total_apps, :pct_1st, :oversubscribed)
|
||||
ON CONFLICT (urn, year) DO UPDATE SET
|
||||
published_admission_number = EXCLUDED.published_admission_number,
|
||||
total_applications = EXCLUDED.total_applications,
|
||||
first_preference_offers_pct = EXCLUDED.first_preference_offers_pct,
|
||||
oversubscribed = EXCLUDED.oversubscribed
|
||||
"""),
|
||||
{
|
||||
"urn": urn, "year": year, "pan": pan,
|
||||
"total_apps": total_apps, "pct_1st": pct_1st,
|
||||
"oversubscribed": oversubscribed,
|
||||
},
|
||||
)
|
||||
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}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
parser.add_argument("--data-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.action in ("download", "all"):
|
||||
download(args.data_dir)
|
||||
if args.action in ("load", "all"):
|
||||
load(data_dir=args.data_dir)
|
||||
@@ -1,148 +0,0 @@
|
||||
"""
|
||||
School Census (SPC) downloader and loader.
|
||||
|
||||
Source: EES publication "schools-pupils-and-their-characteristics"
|
||||
Update: Annual (June)
|
||||
Adds: class_size_avg, ethnicity breakdown by school
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
DEST_DIR = SUPPLEMENTARY_DIR / "census"
|
||||
PUBLICATION_SLUG = "schools-pupils-and-their-characteristics"
|
||||
|
||||
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", ""}
|
||||
|
||||
COLUMN_MAP = {
|
||||
"URN": "urn",
|
||||
"urn": "urn",
|
||||
"YEAR": "year",
|
||||
"Year": "year",
|
||||
# Class size
|
||||
"average_class_size": "class_size_avg",
|
||||
"AVCLAS": "class_size_avg",
|
||||
"avg_class_size": "class_size_avg",
|
||||
# Ethnicity — DfE uses ethnicity major group percentages
|
||||
"perc_white": "ethnicity_white_pct",
|
||||
"perc_asian": "ethnicity_asian_pct",
|
||||
"perc_black": "ethnicity_black_pct",
|
||||
"perc_mixed": "ethnicity_mixed_pct",
|
||||
"perc_other_ethnic": "ethnicity_other_pct",
|
||||
"PTWHITE": "ethnicity_white_pct",
|
||||
"PTASIAN": "ethnicity_asian_pct",
|
||||
"PTBLACK": "ethnicity_black_pct",
|
||||
"PTMIXED": "ethnicity_mixed_pct",
|
||||
"PTOTHER": "ethnicity_other_pct",
|
||||
}
|
||||
|
||||
|
||||
def download(data_dir: Path | None = None) -> Path:
|
||||
dest = (data_dir / "supplementary" / "census") if data_dir else DEST_DIR
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
|
||||
if not url:
|
||||
raise RuntimeError(f"Could not find CSV URL for census publication")
|
||||
|
||||
filename = url.split("/")[-1].split("?")[0] or "census_latest.csv"
|
||||
return download_csv(url, dest / filename)
|
||||
|
||||
|
||||
def _parse_pct(val) -> float | None:
|
||||
if pd.isna(val):
|
||||
return None
|
||||
s = str(val).strip().upper().replace("%", "")
|
||||
if s in NULL_VALUES:
|
||||
return None
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
||||
if path is None:
|
||||
dest = (data_dir / "supplementary" / "census") if data_dir else DEST_DIR
|
||||
files = sorted(dest.glob("*.csv"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No census CSV found in {dest}")
|
||||
path = files[-1]
|
||||
|
||||
print(f" Census: loading {path} ...")
|
||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
|
||||
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]}")
|
||||
|
||||
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))
|
||||
|
||||
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
|
||||
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO school_census
|
||||
(urn, year, class_size_avg,
|
||||
ethnicity_white_pct, ethnicity_asian_pct, ethnicity_black_pct,
|
||||
ethnicity_mixed_pct, ethnicity_other_pct)
|
||||
VALUES (:urn, :year, :class_size_avg,
|
||||
:white, :asian, :black, :mixed, :other)
|
||||
ON CONFLICT (urn, year) DO UPDATE SET
|
||||
class_size_avg = EXCLUDED.class_size_avg,
|
||||
ethnicity_white_pct = EXCLUDED.ethnicity_white_pct,
|
||||
ethnicity_asian_pct = EXCLUDED.ethnicity_asian_pct,
|
||||
ethnicity_black_pct = EXCLUDED.ethnicity_black_pct,
|
||||
ethnicity_mixed_pct = EXCLUDED.ethnicity_mixed_pct,
|
||||
ethnicity_other_pct = EXCLUDED.ethnicity_other_pct
|
||||
"""),
|
||||
{
|
||||
"urn": urn,
|
||||
"year": row_year,
|
||||
"class_size_avg": _parse_pct(row.get("class_size_avg")),
|
||||
"white": _parse_pct(row.get("ethnicity_white_pct")),
|
||||
"asian": _parse_pct(row.get("ethnicity_asian_pct")),
|
||||
"black": _parse_pct(row.get("ethnicity_black_pct")),
|
||||
"mixed": _parse_pct(row.get("ethnicity_mixed_pct")),
|
||||
"other": _parse_pct(row.get("ethnicity_other_pct")),
|
||||
},
|
||||
)
|
||||
inserted += 1
|
||||
if inserted % 5000 == 0:
|
||||
session.flush()
|
||||
|
||||
print(f" Census: upserted {inserted} records")
|
||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
parser.add_argument("--data-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.action in ("download", "all"):
|
||||
download(args.data_dir)
|
||||
if args.action in ("load", "all"):
|
||||
load(data_dir=args.data_dir)
|
||||
@@ -1,111 +0,0 @@
|
||||
"""
|
||||
Shared EES (Explore Education Statistics) API client.
|
||||
|
||||
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 io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
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 (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", [])
|
||||
|
||||
|
||||
def get_latest_csv_url(publication_slug: str, keyword: str = "") -> Optional[str]:
|
||||
"""
|
||||
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)
|
||||
for entry in files:
|
||||
name = entry.get("name", "").lower()
|
||||
if keyword and keyword.lower() not in name:
|
||||
continue
|
||||
csv_url = entry.get("csvDownloadUrl") or entry.get("file", {}).get("url")
|
||||
if csv_url:
|
||||
return csv_url
|
||||
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():
|
||||
print(f" EES: {dest_path.name} already exists, skipping.")
|
||||
return dest_path
|
||||
print(f" EES: downloading {url} ...")
|
||||
resp = requests.get(url, timeout=300, stream=True)
|
||||
resp.raise_for_status()
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
print(f" EES: saved {dest_path} ({dest_path.stat().st_size // 1024} KB)")
|
||||
return dest_path
|
||||
@@ -1,143 +0,0 @@
|
||||
"""
|
||||
FBIT (Financial Benchmarking and Insights Tool) financial data loader.
|
||||
|
||||
Source: https://schools-financial-benchmarking.service.gov.uk/api/
|
||||
Update: Annual (December — data for the prior financial year)
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from config import SUPPLEMENTARY_DIR
|
||||
from db import get_session
|
||||
|
||||
DEST_DIR = SUPPLEMENTARY_DIR / "finance"
|
||||
API_BASE = "https://schools-financial-benchmarking.service.gov.uk/api"
|
||||
RATE_LIMIT_DELAY = 0.1 # seconds between requests
|
||||
|
||||
|
||||
def download(data_dir: Path | None = None) -> Path:
|
||||
"""
|
||||
Fetch per-URN financial data from FBIT API and save as CSV.
|
||||
Batches all school URNs from the database.
|
||||
"""
|
||||
dest = (data_dir / "supplementary" / "finance") if data_dir else DEST_DIR
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Determine year from API (use current year minus 1 for completed financials)
|
||||
from datetime import date
|
||||
year = date.today().year - 1
|
||||
dest_file = dest / f"fbit_{year}.csv"
|
||||
|
||||
if dest_file.exists():
|
||||
print(f" Finance: {dest_file.name} already exists, skipping download.")
|
||||
return dest_file
|
||||
|
||||
# Get all URNs from the database
|
||||
with get_session() as session:
|
||||
from sqlalchemy import text
|
||||
rows = session.execute(text("SELECT urn FROM schools")).fetchall()
|
||||
urns = [r[0] for r in rows]
|
||||
print(f" Finance: fetching FBIT data for {len(urns)} schools (year {year}) ...")
|
||||
|
||||
records = []
|
||||
errors = 0
|
||||
for i, urn in enumerate(urns):
|
||||
if i % 500 == 0:
|
||||
print(f" {i}/{len(urns)} ...")
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{API_BASE}/schoolFinancialDataObject/{urn}",
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data:
|
||||
records.append({
|
||||
"urn": urn,
|
||||
"year": year,
|
||||
"per_pupil_spend": data.get("totalExpenditure") and
|
||||
data.get("numberOfPupils") and
|
||||
round(data["totalExpenditure"] / data["numberOfPupils"], 2),
|
||||
"staff_cost_pct": data.get("staffCostPercent"),
|
||||
"teacher_cost_pct": data.get("teachingStaffCostPercent"),
|
||||
"support_staff_cost_pct": data.get("educationSupportStaffCostPercent"),
|
||||
"premises_cost_pct": data.get("premisesStaffCostPercent"),
|
||||
})
|
||||
elif resp.status_code not in (404, 400):
|
||||
errors += 1
|
||||
except Exception:
|
||||
errors += 1
|
||||
|
||||
time.sleep(RATE_LIMIT_DELAY)
|
||||
|
||||
df = pd.DataFrame(records)
|
||||
df.to_csv(dest_file, index=False)
|
||||
print(f" Finance: saved {len(records)} records to {dest_file} ({errors} errors)")
|
||||
return dest_file
|
||||
|
||||
|
||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
||||
if path is None:
|
||||
dest = (data_dir / "supplementary" / "finance") if data_dir else DEST_DIR
|
||||
files = sorted(dest.glob("fbit_*.csv"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No finance CSV found in {dest}")
|
||||
path = files[-1]
|
||||
|
||||
print(f" Finance: loading {path} ...")
|
||||
df = pd.read_csv(path)
|
||||
|
||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
||||
df = df.dropna(subset=["urn"])
|
||||
df["urn"] = df["urn"].astype(int)
|
||||
|
||||
inserted = 0
|
||||
with get_session() as session:
|
||||
from sqlalchemy import text
|
||||
for _, row in df.iterrows():
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO school_finance
|
||||
(urn, year, per_pupil_spend, staff_cost_pct, teacher_cost_pct,
|
||||
support_staff_cost_pct, premises_cost_pct)
|
||||
VALUES (:urn, :year, :per_pupil, :staff, :teacher, :support, :premises)
|
||||
ON CONFLICT (urn, year) DO UPDATE SET
|
||||
per_pupil_spend = EXCLUDED.per_pupil_spend,
|
||||
staff_cost_pct = EXCLUDED.staff_cost_pct,
|
||||
teacher_cost_pct = EXCLUDED.teacher_cost_pct,
|
||||
support_staff_cost_pct = EXCLUDED.support_staff_cost_pct,
|
||||
premises_cost_pct = EXCLUDED.premises_cost_pct
|
||||
"""),
|
||||
{
|
||||
"urn": int(row["urn"]),
|
||||
"year": int(row["year"]),
|
||||
"per_pupil": float(row["per_pupil_spend"]) if pd.notna(row.get("per_pupil_spend")) else None,
|
||||
"staff": float(row["staff_cost_pct"]) if pd.notna(row.get("staff_cost_pct")) else None,
|
||||
"teacher": float(row["teacher_cost_pct"]) if pd.notna(row.get("teacher_cost_pct")) else None,
|
||||
"support": float(row["support_staff_cost_pct"]) if pd.notna(row.get("support_staff_cost_pct")) else None,
|
||||
"premises": float(row["premises_cost_pct"]) if pd.notna(row.get("premises_cost_pct")) else None,
|
||||
},
|
||||
)
|
||||
inserted += 1
|
||||
if inserted % 2000 == 0:
|
||||
session.flush()
|
||||
|
||||
print(f" Finance: upserted {inserted} records")
|
||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
parser.add_argument("--data-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.action in ("download", "all"):
|
||||
download(args.data_dir)
|
||||
if args.action in ("load", "all"):
|
||||
load(data_dir=args.data_dir)
|
||||
@@ -1,159 +0,0 @@
|
||||
"""
|
||||
GIAS (Get Information About Schools) bulk CSV downloader and loader.
|
||||
|
||||
Source: https://get-information-schools.service.gov.uk/Downloads
|
||||
Update: Daily; we refresh weekly.
|
||||
Adds: website, headteacher_name, capacity, trust_name, trust_uid, gender, nursery_provision
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from config import SUPPLEMENTARY_DIR
|
||||
from db import get_session
|
||||
|
||||
DEST_DIR = SUPPLEMENTARY_DIR / "gias"
|
||||
|
||||
# GIAS bulk download URL — date is injected at runtime
|
||||
GIAS_URL_TEMPLATE = "https://ea-edubase-api-prod.azurewebsites.net/edubase/downloads/public/edubasealldata{date}.csv"
|
||||
|
||||
COLUMN_MAP = {
|
||||
"URN": "urn",
|
||||
"SchoolWebsite": "website",
|
||||
"SchoolCapacity": "capacity",
|
||||
"TrustName": "trust_name",
|
||||
"TrustUID": "trust_uid",
|
||||
"Gender (name)": "gender",
|
||||
"NurseryProvision (name)": "nursery_provision_raw",
|
||||
"HeadTitle": "head_title",
|
||||
"HeadFirstName": "head_first",
|
||||
"HeadLastName": "head_last",
|
||||
}
|
||||
|
||||
|
||||
def download(data_dir: Path | None = None) -> Path:
|
||||
dest = (data_dir / "supplementary" / "gias") if data_dir else DEST_DIR
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
today = date.today().strftime("%Y%m%d")
|
||||
url = GIAS_URL_TEMPLATE.format(date=today)
|
||||
filename = f"gias_{today}.csv"
|
||||
dest_file = dest / filename
|
||||
|
||||
if dest_file.exists():
|
||||
print(f" GIAS: {filename} already exists, skipping download.")
|
||||
return dest_file
|
||||
|
||||
print(f" GIAS: downloading {url} ...")
|
||||
resp = requests.get(url, timeout=300, stream=True)
|
||||
|
||||
# GIAS may not have today's file yet — fall back to yesterday
|
||||
if resp.status_code == 404:
|
||||
from datetime import timedelta
|
||||
yesterday = (date.today() - timedelta(days=1)).strftime("%Y%m%d")
|
||||
url = GIAS_URL_TEMPLATE.format(date=yesterday)
|
||||
filename = f"gias_{yesterday}.csv"
|
||||
dest_file = dest / filename
|
||||
if dest_file.exists():
|
||||
print(f" GIAS: {filename} already exists, skipping download.")
|
||||
return dest_file
|
||||
resp = requests.get(url, timeout=300, stream=True)
|
||||
|
||||
resp.raise_for_status()
|
||||
with open(dest_file, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
|
||||
print(f" GIAS: saved {dest_file} ({dest_file.stat().st_size // 1024} KB)")
|
||||
return dest_file
|
||||
|
||||
|
||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
||||
if path is None:
|
||||
dest = (data_dir / "supplementary" / "gias") if data_dir else DEST_DIR
|
||||
files = sorted(dest.glob("gias_*.csv"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No GIAS CSV found in {dest}")
|
||||
path = files[-1]
|
||||
|
||||
print(f" GIAS: loading {path} ...")
|
||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
|
||||
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]}")
|
||||
|
||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
||||
df = df.dropna(subset=["urn"])
|
||||
df["urn"] = df["urn"].astype(int)
|
||||
|
||||
# Build headteacher_name from parts
|
||||
def build_name(row):
|
||||
parts = [
|
||||
str(row.get("head_title", "") or "").strip(),
|
||||
str(row.get("head_first", "") or "").strip(),
|
||||
str(row.get("head_last", "") or "").strip(),
|
||||
]
|
||||
return " ".join(p for p in parts if p) or None
|
||||
|
||||
df["headteacher_name"] = df.apply(build_name, axis=1)
|
||||
df["nursery_provision"] = df.get("nursery_provision_raw", pd.Series()).apply(
|
||||
lambda v: True if str(v).strip().lower().startswith("has") else False if pd.notna(v) else None
|
||||
)
|
||||
|
||||
def clean_str(val):
|
||||
s = str(val).strip() if pd.notna(val) else None
|
||||
return s if s and s.lower() not in ("nan", "none", "") else None
|
||||
|
||||
updated = 0
|
||||
with get_session() as session:
|
||||
from sqlalchemy import text
|
||||
for _, row in df.iterrows():
|
||||
urn = int(row["urn"])
|
||||
session.execute(
|
||||
text("""
|
||||
UPDATE schools SET
|
||||
website = :website,
|
||||
headteacher_name = :headteacher_name,
|
||||
capacity = :capacity,
|
||||
trust_name = :trust_name,
|
||||
trust_uid = :trust_uid,
|
||||
gender = :gender,
|
||||
nursery_provision = :nursery_provision
|
||||
WHERE urn = :urn
|
||||
"""),
|
||||
{
|
||||
"urn": urn,
|
||||
"website": clean_str(row.get("website")),
|
||||
"headteacher_name": row.get("headteacher_name"),
|
||||
"capacity": int(row["capacity"]) if pd.notna(row.get("capacity")) and str(row.get("capacity")).strip().isdigit() else None,
|
||||
"trust_name": clean_str(row.get("trust_name")),
|
||||
"trust_uid": clean_str(row.get("trust_uid")),
|
||||
"gender": clean_str(row.get("gender")),
|
||||
"nursery_provision": row.get("nursery_provision"),
|
||||
},
|
||||
)
|
||||
updated += 1
|
||||
if updated % 5000 == 0:
|
||||
session.flush()
|
||||
print(f" Updated {updated} schools...")
|
||||
|
||||
print(f" GIAS: updated {updated} school records")
|
||||
return {"inserted": 0, "updated": updated, "skipped": 0}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
parser.add_argument("--data-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action in ("download", "all"):
|
||||
path = download(args.data_dir)
|
||||
if args.action in ("load", "all"):
|
||||
load(data_dir=args.data_dir)
|
||||
@@ -1,176 +0,0 @@
|
||||
"""
|
||||
IDACI (Income Deprivation Affecting Children Index) loader.
|
||||
|
||||
Source: English Indices of Deprivation 2019
|
||||
https://www.gov.uk/government/statistics/english-indices-of-deprivation-2019
|
||||
|
||||
This is a one-time download (5-yearly release). We join school postcodes to LSOAs
|
||||
via postcodes.io, then look up IDACI scores from the IoD2019 file.
|
||||
|
||||
Update: ~5-yearly (next release expected 2025/26)
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from config import SUPPLEMENTARY_DIR
|
||||
from db import get_session
|
||||
|
||||
DEST_DIR = SUPPLEMENTARY_DIR / "idaci"
|
||||
|
||||
# IoD 2019 supplementary data — "Income Deprivation Affecting Children Index (IDACI)"
|
||||
IOD_2019_URL = (
|
||||
"https://assets.publishing.service.gov.uk/government/uploads/system/uploads/"
|
||||
"attachment_data/file/833970/File_1_-_IMD2019_Index_of_Multiple_Deprivation.xlsx"
|
||||
)
|
||||
|
||||
POSTCODES_IO_BATCH = "https://api.postcodes.io/postcodes"
|
||||
BATCH_SIZE = 100
|
||||
|
||||
|
||||
def download(data_dir: Path | None = None) -> Path:
|
||||
dest = (data_dir / "supplementary" / "idaci") if data_dir else DEST_DIR
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = "iod2019_idaci.xlsx"
|
||||
dest_file = dest / filename
|
||||
if dest_file.exists():
|
||||
print(f" IDACI: {filename} already exists, skipping download.")
|
||||
return dest_file
|
||||
|
||||
print(f" IDACI: downloading IoD2019 file ...")
|
||||
resp = requests.get(IOD_2019_URL, timeout=300, stream=True)
|
||||
resp.raise_for_status()
|
||||
with open(dest_file, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
|
||||
print(f" IDACI: saved {dest_file}")
|
||||
return dest_file
|
||||
|
||||
|
||||
def _postcode_to_lsoa(postcodes: list[str]) -> dict[str, str]:
|
||||
"""Batch-resolve postcodes to LSOA codes via postcodes.io."""
|
||||
result = {}
|
||||
valid = [p.strip().upper() for p in postcodes if p and len(str(p).strip()) >= 5]
|
||||
valid = list(set(valid))
|
||||
|
||||
for i in range(0, len(valid), BATCH_SIZE):
|
||||
batch = valid[i:i + BATCH_SIZE]
|
||||
try:
|
||||
resp = requests.post(POSTCODES_IO_BATCH, json={"postcodes": batch}, timeout=30)
|
||||
if resp.status_code == 200:
|
||||
for item in resp.json().get("result", []):
|
||||
if item and item.get("result"):
|
||||
lsoa = item["result"].get("lsoa")
|
||||
if lsoa:
|
||||
result[item["query"].upper()] = lsoa
|
||||
except Exception as e:
|
||||
print(f" Warning: postcodes.io batch failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
||||
dest = (data_dir / "supplementary" / "idaci") if data_dir else DEST_DIR
|
||||
if path is None:
|
||||
files = sorted(dest.glob("*.xlsx"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No IDACI file found in {dest}")
|
||||
path = files[-1]
|
||||
|
||||
print(f" IDACI: loading IoD2019 from {path} ...")
|
||||
|
||||
# IoD2019 File 1 — sheet "IoD2019 IDACI" or similar
|
||||
try:
|
||||
iod_df = pd.read_excel(path, sheet_name=None)
|
||||
# Find sheet with IDACI data
|
||||
idaci_sheet = None
|
||||
for name, df in iod_df.items():
|
||||
if "IDACI" in name.upper() or "IDACI" in str(df.columns.tolist()).upper():
|
||||
idaci_sheet = name
|
||||
break
|
||||
if idaci_sheet is None:
|
||||
idaci_sheet = list(iod_df.keys())[0]
|
||||
df_iod = iod_df[idaci_sheet]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not read IoD2019 file: {e}")
|
||||
|
||||
# Normalise column names — IoD2019 uses specific headers
|
||||
col_lsoa = next((c for c in df_iod.columns if "LSOA" in str(c).upper() and "code" in str(c).lower()), None)
|
||||
col_score = next((c for c in df_iod.columns if "IDACI" in str(c).upper() and "score" in str(c).lower()), None)
|
||||
col_rank = next((c for c in df_iod.columns if "IDACI" in str(c).upper() and "rank" in str(c).lower()), None)
|
||||
|
||||
if not col_lsoa or not col_score:
|
||||
print(f" IDACI columns available: {list(df_iod.columns)[:20]}")
|
||||
raise ValueError("Could not find LSOA code or IDACI score columns")
|
||||
|
||||
df_iod = df_iod[[col_lsoa, col_score]].copy()
|
||||
df_iod.columns = ["lsoa_code", "idaci_score"]
|
||||
df_iod = df_iod.dropna()
|
||||
|
||||
# Compute decile from rank (or from score distribution)
|
||||
total = len(df_iod)
|
||||
df_iod = df_iod.sort_values("idaci_score", ascending=False)
|
||||
df_iod["idaci_decile"] = (pd.qcut(df_iod["idaci_score"], 10, labels=False) + 1).astype(int)
|
||||
# Decile 1 = most deprived (highest IDACI score)
|
||||
df_iod["idaci_decile"] = 11 - df_iod["idaci_decile"]
|
||||
|
||||
lsoa_lookup = df_iod.set_index("lsoa_code")[["idaci_score", "idaci_decile"]].to_dict("index")
|
||||
print(f" IDACI: loaded {len(lsoa_lookup)} LSOA records")
|
||||
|
||||
# Fetch all school postcodes from the database
|
||||
with get_session() as session:
|
||||
from sqlalchemy import text
|
||||
rows = session.execute(text("SELECT urn, postcode FROM schools WHERE postcode IS NOT NULL")).fetchall()
|
||||
|
||||
postcodes = [r[1] for r in rows]
|
||||
print(f" IDACI: resolving {len(postcodes)} postcodes via postcodes.io ...")
|
||||
pc_to_lsoa = _postcode_to_lsoa(postcodes)
|
||||
print(f" IDACI: resolved {len(pc_to_lsoa)} postcodes to LSOAs")
|
||||
|
||||
inserted = skipped = 0
|
||||
with get_session() as session:
|
||||
from sqlalchemy import text
|
||||
for urn, postcode in rows:
|
||||
lsoa = pc_to_lsoa.get(str(postcode).strip().upper())
|
||||
if not lsoa:
|
||||
skipped += 1
|
||||
continue
|
||||
iod = lsoa_lookup.get(lsoa)
|
||||
if not iod:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO school_deprivation (urn, lsoa_code, idaci_score, idaci_decile)
|
||||
VALUES (:urn, :lsoa, :score, :decile)
|
||||
ON CONFLICT (urn) DO UPDATE SET
|
||||
lsoa_code = EXCLUDED.lsoa_code,
|
||||
idaci_score = EXCLUDED.idaci_score,
|
||||
idaci_decile = EXCLUDED.idaci_decile
|
||||
"""),
|
||||
{"urn": urn, "lsoa": lsoa, "score": float(iod["idaci_score"]), "decile": int(iod["idaci_decile"])},
|
||||
)
|
||||
inserted += 1
|
||||
if inserted % 2000 == 0:
|
||||
session.flush()
|
||||
|
||||
print(f" IDACI: upserted {inserted}, skipped {skipped}")
|
||||
return {"inserted": inserted, "updated": 0, "skipped": skipped}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
parser.add_argument("--data-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.action in ("download", "all"):
|
||||
download(args.data_dir)
|
||||
if args.action in ("load", "all"):
|
||||
load(data_dir=args.data_dir)
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
KS2 attainment data re-importer.
|
||||
|
||||
Triggers a full re-import of the KS2 CSV data by calling the backend's
|
||||
admin endpoint. The backend owns the migration logic and CSV column mappings;
|
||||
this module is a thin trigger so the re-import can be orchestrated via Kestra
|
||||
like all other data sources.
|
||||
|
||||
The CSV files must already be present in the data volume under
|
||||
/data/{year}/england_ks2final.csv
|
||||
(populated at deploy time from the repo's data/ directory).
|
||||
"""
|
||||
import requests
|
||||
from config import BACKEND_URL, ADMIN_API_KEY
|
||||
|
||||
HEADERS = {"X-API-Key": ADMIN_API_KEY}
|
||||
|
||||
|
||||
def download():
|
||||
"""No download step — CSVs are shipped with the repo."""
|
||||
print("KS2 CSVs are bundled in the data volume; no download needed.")
|
||||
return {"skipped": True}
|
||||
|
||||
|
||||
def load():
|
||||
"""Trigger KS2 re-import on the backend and return immediately.
|
||||
|
||||
The migration (including geocoding) runs as a background thread on the
|
||||
backend and can take up to an hour. Poll GET /api/admin/reimport-ks2/status
|
||||
to check progress, or simply wait for schools to appear in the UI.
|
||||
"""
|
||||
url = f"{BACKEND_URL}/api/admin/reimport-ks2?geocode=true"
|
||||
print(f"POST {url}")
|
||||
resp = requests.post(url, headers=HEADERS, timeout=30)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
print(f"Result: {result}")
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
args = parser.parse_args()
|
||||
if args.action in ("download", "all"):
|
||||
download()
|
||||
if args.action in ("load", "all"):
|
||||
load()
|
||||
@@ -1,418 +0,0 @@
|
||||
"""
|
||||
Ofsted Monthly Management Information CSV downloader and loader.
|
||||
|
||||
Source: https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes
|
||||
Update: Monthly (released ~2 weeks into each month)
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from config import SUPPLEMENTARY_DIR
|
||||
from db import get_session
|
||||
|
||||
# Current Ofsted MI download URL — update this when Ofsted releases a new file.
|
||||
# The URL follows a predictable pattern; we attempt to discover it from the GOV.UK page.
|
||||
GOV_UK_PAGE = "https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes"
|
||||
|
||||
# Column name → internal field, listed in priority order per field.
|
||||
# First matching column wins; later entries are fallbacks for older file formats.
|
||||
COLUMN_PRIORITY = {
|
||||
"urn": ["URN", "Urn", "urn"],
|
||||
"inspection_date": [
|
||||
"Inspection start date of latest OEIF graded inspection",
|
||||
"Inspection start date",
|
||||
"Inspection date",
|
||||
"InspectionDate",
|
||||
],
|
||||
"publication_date": [
|
||||
"Publication date of latest OEIF graded inspection",
|
||||
"Publication date",
|
||||
"PublicationDate",
|
||||
],
|
||||
"inspection_type": [
|
||||
"Inspection type of latest OEIF graded inspection",
|
||||
"Inspection type",
|
||||
"InspectionType",
|
||||
],
|
||||
"overall_effectiveness": [
|
||||
"Latest OEIF overall effectiveness",
|
||||
"Overall effectiveness",
|
||||
"OverallEffectiveness",
|
||||
],
|
||||
"quality_of_education": [
|
||||
"Latest OEIF quality of education",
|
||||
"Quality of education",
|
||||
"QualityOfEducation",
|
||||
],
|
||||
"behaviour_attitudes": [
|
||||
"Latest OEIF behaviour and attitudes",
|
||||
"Behaviour and attitudes",
|
||||
"BehaviourAndAttitudes",
|
||||
],
|
||||
"personal_development": [
|
||||
"Latest OEIF personal development",
|
||||
"Personal development",
|
||||
"PersonalDevelopment",
|
||||
],
|
||||
"leadership_management": [
|
||||
"Latest OEIF effectiveness of leadership and management",
|
||||
"Leadership and management",
|
||||
"LeadershipAndManagement",
|
||||
],
|
||||
"early_years_provision": [
|
||||
"Latest OEIF early years provision (where applicable)",
|
||||
"Early years provision",
|
||||
"EarlyYearsProvision",
|
||||
],
|
||||
}
|
||||
|
||||
GRADE_MAP = {
|
||||
"Outstanding": 1, "1": 1, 1: 1,
|
||||
"Good": 2, "2": 2, 2: 2,
|
||||
"Requires improvement": 3, "3": 3, 3: 3,
|
||||
"Requires Improvement": 3,
|
||||
"Inadequate": 4, "4": 4, 4: 4,
|
||||
}
|
||||
|
||||
# Report Card grade text → integer (1=Exceptional … 5=Urgent improvement)
|
||||
RC_GRADE_MAP = {
|
||||
"exceptional": 1,
|
||||
"strong standard": 2,
|
||||
"strong": 2,
|
||||
"expected standard": 3,
|
||||
"expected": 3,
|
||||
"needs attention": 4,
|
||||
"urgent improvement": 5,
|
||||
}
|
||||
|
||||
# Column name priority for Report Card fields (best-guess names; Ofsted may vary)
|
||||
RC_COLUMN_PRIORITY = {
|
||||
"rc_safeguarding": [
|
||||
"Safeguarding",
|
||||
"safeguarding",
|
||||
"Safeguarding standards",
|
||||
],
|
||||
"rc_inclusion": [
|
||||
"Inclusion",
|
||||
"inclusion",
|
||||
],
|
||||
"rc_curriculum_teaching": [
|
||||
"Curriculum and teaching",
|
||||
"curriculum_and_teaching",
|
||||
"Curriculum & teaching",
|
||||
],
|
||||
"rc_achievement": [
|
||||
"Achievement",
|
||||
"achievement",
|
||||
],
|
||||
"rc_attendance_behaviour": [
|
||||
"Attendance and behaviour",
|
||||
"attendance_and_behaviour",
|
||||
"Attendance & behaviour",
|
||||
],
|
||||
"rc_personal_development": [
|
||||
"Personal development and well-being",
|
||||
"Personal development and wellbeing",
|
||||
"personal_development_and_wellbeing",
|
||||
"Personal development & well-being",
|
||||
],
|
||||
"rc_leadership_governance": [
|
||||
"Leadership and governance",
|
||||
"leadership_and_governance",
|
||||
"Leadership & governance",
|
||||
],
|
||||
"rc_early_years": [
|
||||
"Early years",
|
||||
"early_years",
|
||||
"Early years provision",
|
||||
],
|
||||
"rc_sixth_form": [
|
||||
"Sixth form",
|
||||
"sixth_form",
|
||||
"Sixth form in schools",
|
||||
],
|
||||
}
|
||||
|
||||
DEST_DIR = SUPPLEMENTARY_DIR / "ofsted"
|
||||
|
||||
|
||||
def _discover_csv_url() -> str | None:
|
||||
"""Scrape the GOV.UK page for the most recent CSV/ZIP link."""
|
||||
try:
|
||||
resp = requests.get(GOV_UK_PAGE, timeout=30)
|
||||
resp.raise_for_status()
|
||||
# Look for links to assets.publishing.service.gov.uk CSV or ZIP files
|
||||
pattern = r'href="(https://assets\.publishing\.service\.gov\.uk[^"]+\.(?:csv|zip))"'
|
||||
urls = re.findall(pattern, resp.text, re.IGNORECASE)
|
||||
if urls:
|
||||
return urls[0]
|
||||
except Exception as e:
|
||||
print(f" Warning: could not scrape GOV.UK page: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def download(data_dir: Path | None = None) -> Path:
|
||||
dest = (data_dir / "supplementary" / "ofsted") if data_dir else DEST_DIR
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
url = _discover_csv_url()
|
||||
if not url:
|
||||
raise RuntimeError(
|
||||
"Could not discover Ofsted MI download URL. "
|
||||
"Visit https://www.gov.uk/government/statistical-data-sets/"
|
||||
"monthly-management-information-ofsteds-school-inspections-outcomes "
|
||||
"to get the latest URL and update MANUAL_URL in ofsted.py"
|
||||
)
|
||||
|
||||
filename = url.split("/")[-1]
|
||||
dest_file = dest / filename
|
||||
|
||||
if dest_file.exists():
|
||||
print(f" Ofsted: {filename} already exists, skipping download.")
|
||||
return dest_file
|
||||
|
||||
print(f" Ofsted: downloading {url} ...")
|
||||
resp = requests.get(url, timeout=120, stream=True)
|
||||
resp.raise_for_status()
|
||||
with open(dest_file, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
|
||||
print(f" Ofsted: saved {dest_file} ({dest_file.stat().st_size // 1024} KB)")
|
||||
return dest_file
|
||||
|
||||
|
||||
def _parse_grade(val) -> int | None:
|
||||
if pd.isna(val):
|
||||
return None
|
||||
key = str(val).strip()
|
||||
return GRADE_MAP.get(key)
|
||||
|
||||
|
||||
def _parse_rc_grade(val) -> int | None:
|
||||
"""Parse a Report Card grade text to integer 1–5."""
|
||||
if pd.isna(val):
|
||||
return None
|
||||
key = str(val).strip().lower()
|
||||
return RC_GRADE_MAP.get(key)
|
||||
|
||||
|
||||
def _parse_safeguarding(val) -> bool | None:
|
||||
"""Parse safeguarding 'Met'/'Not met' to boolean."""
|
||||
if pd.isna(val):
|
||||
return None
|
||||
s = str(val).strip().lower()
|
||||
if s == "met":
|
||||
return True
|
||||
if s in ("not met", "not_met"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date(val) -> date | None:
|
||||
if pd.isna(val):
|
||||
return None
|
||||
for fmt in ("%d/%m/%Y", "%Y-%m-%d", "%d-%m-%Y", "%d %B %Y"):
|
||||
try:
|
||||
return datetime.strptime(str(val).strip(), fmt).date()
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _framework_for_row(row) -> str | None:
|
||||
"""Determine inspection framework for a single school row.
|
||||
|
||||
Check RC columns first — if any have a value, it's a Report Card inspection.
|
||||
Fall back to OEIF columns. If neither has data, the school has no graded
|
||||
inspection on record (return None).
|
||||
"""
|
||||
rc_check_cols = [
|
||||
"rc_inclusion", "rc_curriculum_teaching", "rc_achievement",
|
||||
"rc_attendance_behaviour", "rc_personal_development",
|
||||
"rc_leadership_governance", "rc_safeguarding",
|
||||
]
|
||||
for col in rc_check_cols:
|
||||
val = row.get(col)
|
||||
if val is not None and not (isinstance(val, float) and pd.isna(val)):
|
||||
return "ReportCard"
|
||||
|
||||
oeif_check_cols = ["overall_effectiveness", "quality_of_education"]
|
||||
for col in oeif_check_cols:
|
||||
val = row.get(col)
|
||||
if val is not None and not (isinstance(val, float) and pd.isna(val)):
|
||||
return "OEIF"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
||||
if path is None:
|
||||
dest = (data_dir / "supplementary" / "ofsted") if data_dir else DEST_DIR
|
||||
files = sorted(dest.glob("*.csv")) + sorted(dest.glob("*.zip"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No Ofsted MI file found in {dest}")
|
||||
path = files[-1]
|
||||
|
||||
print(f" Ofsted: loading {path} ...")
|
||||
|
||||
def _find_header_row(filepath, encoding="latin-1"):
|
||||
"""Scan up to 10 rows to find the one containing a URN column."""
|
||||
for i in range(10):
|
||||
peek = pd.read_csv(filepath, encoding=encoding, header=i, nrows=0)
|
||||
if any(str(c).strip() in ("URN", "Urn", "urn") for c in peek.columns):
|
||||
return i
|
||||
return 0
|
||||
|
||||
if str(path).endswith(".zip"):
|
||||
import zipfile, io
|
||||
with zipfile.ZipFile(path) as z:
|
||||
csv_names = [n for n in z.namelist() if n.endswith(".csv")]
|
||||
if not csv_names:
|
||||
raise ValueError("No CSV found inside Ofsted ZIP")
|
||||
# Extract to a temp file so we can scan for the header row
|
||||
import tempfile, os
|
||||
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
|
||||
tmp.write(z.read(csv_names[0]))
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
hdr = _find_header_row(tmp_path)
|
||||
df = pd.read_csv(tmp_path, encoding="latin-1", low_memory=False, header=hdr)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
else:
|
||||
hdr = _find_header_row(path)
|
||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False, header=hdr)
|
||||
|
||||
# Normalise OEIF column names: for each target field pick the first source column present
|
||||
available = set(df.columns)
|
||||
for target, sources in COLUMN_PRIORITY.items():
|
||||
for src in sources:
|
||||
if src in available:
|
||||
df.rename(columns={src: target}, inplace=True)
|
||||
break
|
||||
|
||||
# Normalise Report Card column names (if present)
|
||||
available = set(df.columns)
|
||||
for target, sources in RC_COLUMN_PRIORITY.items():
|
||||
for src in sources:
|
||||
if src in available:
|
||||
df.rename(columns={src: target}, inplace=True)
|
||||
break
|
||||
|
||||
if "urn" not in df.columns:
|
||||
raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}")
|
||||
|
||||
# Only keep rows with a valid URN
|
||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
||||
df = df.dropna(subset=["urn"])
|
||||
df["urn"] = df["urn"].astype(int)
|
||||
|
||||
inserted = updated = skipped = 0
|
||||
|
||||
with get_session() as session:
|
||||
# Keep only the most recent inspection per URN
|
||||
if "inspection_date" in df.columns:
|
||||
df["_date_parsed"] = df["inspection_date"].apply(_parse_date)
|
||||
df = df.sort_values("_date_parsed", ascending=False).groupby("urn").first().reset_index()
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
for _, row in df.iterrows():
|
||||
urn = int(row["urn"])
|
||||
|
||||
record = {
|
||||
"urn": urn,
|
||||
"framework": _framework_for_row(row),
|
||||
"inspection_date": _parse_date(row.get("inspection_date")),
|
||||
"publication_date": _parse_date(row.get("publication_date")),
|
||||
"inspection_type": str(row.get("inspection_type", "")).strip() or None,
|
||||
# OEIF fields
|
||||
"overall_effectiveness": _parse_grade(row.get("overall_effectiveness")),
|
||||
"quality_of_education": _parse_grade(row.get("quality_of_education")),
|
||||
"behaviour_attitudes": _parse_grade(row.get("behaviour_attitudes")),
|
||||
"personal_development": _parse_grade(row.get("personal_development")),
|
||||
"leadership_management": _parse_grade(row.get("leadership_management")),
|
||||
"early_years_provision": _parse_grade(row.get("early_years_provision")),
|
||||
"previous_overall": None,
|
||||
# Report Card fields
|
||||
"rc_safeguarding_met": _parse_safeguarding(row.get("rc_safeguarding")),
|
||||
"rc_inclusion": _parse_rc_grade(row.get("rc_inclusion")),
|
||||
"rc_curriculum_teaching": _parse_rc_grade(row.get("rc_curriculum_teaching")),
|
||||
"rc_achievement": _parse_rc_grade(row.get("rc_achievement")),
|
||||
"rc_attendance_behaviour": _parse_rc_grade(row.get("rc_attendance_behaviour")),
|
||||
"rc_personal_development": _parse_rc_grade(row.get("rc_personal_development")),
|
||||
"rc_leadership_governance": _parse_rc_grade(row.get("rc_leadership_governance")),
|
||||
"rc_early_years": _parse_rc_grade(row.get("rc_early_years")),
|
||||
"rc_sixth_form": _parse_rc_grade(row.get("rc_sixth_form")),
|
||||
}
|
||||
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO ofsted_inspections
|
||||
(urn, framework, inspection_date, publication_date, inspection_type,
|
||||
overall_effectiveness, quality_of_education, behaviour_attitudes,
|
||||
personal_development, leadership_management, early_years_provision,
|
||||
previous_overall,
|
||||
rc_safeguarding_met, rc_inclusion, rc_curriculum_teaching,
|
||||
rc_achievement, rc_attendance_behaviour, rc_personal_development,
|
||||
rc_leadership_governance, rc_early_years, rc_sixth_form)
|
||||
VALUES
|
||||
(:urn, :framework, :inspection_date, :publication_date, :inspection_type,
|
||||
:overall_effectiveness, :quality_of_education, :behaviour_attitudes,
|
||||
:personal_development, :leadership_management, :early_years_provision,
|
||||
:previous_overall,
|
||||
:rc_safeguarding_met, :rc_inclusion, :rc_curriculum_teaching,
|
||||
:rc_achievement, :rc_attendance_behaviour, :rc_personal_development,
|
||||
:rc_leadership_governance, :rc_early_years, :rc_sixth_form)
|
||||
ON CONFLICT (urn) DO UPDATE SET
|
||||
previous_overall = ofsted_inspections.overall_effectiveness,
|
||||
framework = EXCLUDED.framework,
|
||||
inspection_date = EXCLUDED.inspection_date,
|
||||
publication_date = EXCLUDED.publication_date,
|
||||
inspection_type = EXCLUDED.inspection_type,
|
||||
overall_effectiveness = EXCLUDED.overall_effectiveness,
|
||||
quality_of_education = EXCLUDED.quality_of_education,
|
||||
behaviour_attitudes = EXCLUDED.behaviour_attitudes,
|
||||
personal_development = EXCLUDED.personal_development,
|
||||
leadership_management = EXCLUDED.leadership_management,
|
||||
early_years_provision = EXCLUDED.early_years_provision,
|
||||
rc_safeguarding_met = EXCLUDED.rc_safeguarding_met,
|
||||
rc_inclusion = EXCLUDED.rc_inclusion,
|
||||
rc_curriculum_teaching = EXCLUDED.rc_curriculum_teaching,
|
||||
rc_achievement = EXCLUDED.rc_achievement,
|
||||
rc_attendance_behaviour = EXCLUDED.rc_attendance_behaviour,
|
||||
rc_personal_development = EXCLUDED.rc_personal_development,
|
||||
rc_leadership_governance = EXCLUDED.rc_leadership_governance,
|
||||
rc_early_years = EXCLUDED.rc_early_years,
|
||||
rc_sixth_form = EXCLUDED.rc_sixth_form
|
||||
"""),
|
||||
record,
|
||||
)
|
||||
inserted += 1
|
||||
|
||||
if inserted % 5000 == 0:
|
||||
session.flush()
|
||||
print(f" Processed {inserted} records...")
|
||||
|
||||
print(f" Ofsted: upserted {inserted} records")
|
||||
return {"inserted": inserted, "updated": updated, "skipped": skipped}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
parser.add_argument("--data-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action in ("download", "all"):
|
||||
path = download(args.data_dir)
|
||||
if args.action in ("load", "all"):
|
||||
load(data_dir=args.data_dir)
|
||||
@@ -1,229 +0,0 @@
|
||||
"""
|
||||
Ofsted Parent View open data downloader and loader.
|
||||
|
||||
Source: https://parentview.ofsted.gov.uk/open-data
|
||||
Update: ~3 times/year (Spring, Autumn, Summer)
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from config import SUPPLEMENTARY_DIR
|
||||
from db import get_session
|
||||
|
||||
DEST_DIR = SUPPLEMENTARY_DIR / "parent_view"
|
||||
OPEN_DATA_PAGE = "https://parentview.ofsted.gov.uk/open-data"
|
||||
|
||||
# Question column mapping — Parent View open data uses descriptive column headers
|
||||
# Map any variant to our internal field names
|
||||
QUESTION_MAP = {
|
||||
# Q1 — happiness
|
||||
"My child is happy at this school": "q_happy_pct",
|
||||
"Happy": "q_happy_pct",
|
||||
# Q2 — safety
|
||||
"My child feels safe at this school": "q_safe_pct",
|
||||
"Safe": "q_safe_pct",
|
||||
# Q3 — bullying
|
||||
"The school makes sure its pupils are well behaved": "q_behaviour_pct",
|
||||
"Well Behaved": "q_behaviour_pct",
|
||||
# Q4 — bullying dealt with (sometimes separate)
|
||||
"My child has been bullied and the school dealt with the bullying quickly and effectively": "q_bullying_pct",
|
||||
"Bullying": "q_bullying_pct",
|
||||
# Q5 — curriculum info
|
||||
"The school makes me aware of what my child will learn during the year": "q_communication_pct",
|
||||
"Aware of learning": "q_communication_pct",
|
||||
# Q6 — concerns dealt with
|
||||
"When I have raised concerns with the school, they have been dealt with properly": "q_communication_pct",
|
||||
# Q7 — child does well
|
||||
"My child does well at this school": "q_progress_pct",
|
||||
"Does well": "q_progress_pct",
|
||||
# Q8 — teaching
|
||||
"The teaching is good at this school": "q_teaching_pct",
|
||||
"Good teaching": "q_teaching_pct",
|
||||
# Q9 — progress info
|
||||
"I receive valuable information from the school about my child's progress": "q_information_pct",
|
||||
"Progress information": "q_information_pct",
|
||||
# Q10 — curriculum breadth
|
||||
"My child is taught a broad range of subjects": "q_curriculum_pct",
|
||||
"Broad subjects": "q_curriculum_pct",
|
||||
# Q11 — prepares for future
|
||||
"The school prepares my child well for the future": "q_future_pct",
|
||||
"Prepared for future": "q_future_pct",
|
||||
# Q12 — leadership
|
||||
"The school is led and managed effectively": "q_leadership_pct",
|
||||
"Led well": "q_leadership_pct",
|
||||
# Q13 — wellbeing
|
||||
"The school supports my child's wider personal development": "q_wellbeing_pct",
|
||||
"Personal development": "q_wellbeing_pct",
|
||||
# Q14 — recommendation
|
||||
"I would recommend this school to another parent": "q_recommend_pct",
|
||||
"Recommend": "q_recommend_pct",
|
||||
}
|
||||
|
||||
|
||||
def download(data_dir: Path | None = None) -> Path:
|
||||
dest = (data_dir / "supplementary" / "parent_view") if data_dir else DEST_DIR
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Scrape the open data page for the download link
|
||||
try:
|
||||
resp = requests.get(OPEN_DATA_PAGE, timeout=30)
|
||||
resp.raise_for_status()
|
||||
pattern = r'href="([^"]+\.(?:xlsx|csv|zip))"'
|
||||
urls = re.findall(pattern, resp.text, re.IGNORECASE)
|
||||
if not urls:
|
||||
raise RuntimeError("No download link found on Parent View open data page")
|
||||
url = urls[0] if urls[0].startswith("http") else "https://parentview.ofsted.gov.uk" + urls[0]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not discover Parent View download URL: {e}")
|
||||
|
||||
filename = url.split("/")[-1].split("?")[0]
|
||||
dest_file = dest / filename
|
||||
|
||||
if dest_file.exists():
|
||||
print(f" ParentView: {filename} already exists, skipping download.")
|
||||
return dest_file
|
||||
|
||||
print(f" ParentView: downloading {url} ...")
|
||||
resp = requests.get(url, timeout=120, stream=True)
|
||||
resp.raise_for_status()
|
||||
with open(dest_file, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
|
||||
print(f" ParentView: saved {dest_file}")
|
||||
return dest_file
|
||||
|
||||
|
||||
def _positive_pct(row: pd.Series, q_col_base: str) -> float | None:
|
||||
"""Sum 'Strongly agree' + 'Agree' percentages for a question."""
|
||||
# Parent View open data has columns like "Q1 - Strongly agree %", "Q1 - Agree %"
|
||||
strongly = row.get(f"{q_col_base} - Strongly agree %") or row.get(f"{q_col_base} - Strongly Agree %")
|
||||
agree = row.get(f"{q_col_base} - Agree %")
|
||||
try:
|
||||
total = 0.0
|
||||
if pd.notna(strongly):
|
||||
total += float(strongly)
|
||||
if pd.notna(agree):
|
||||
total += float(agree)
|
||||
return round(total, 1) if total > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
||||
if path is None:
|
||||
dest = (data_dir / "supplementary" / "parent_view") if data_dir else DEST_DIR
|
||||
files = sorted(dest.glob("*.xlsx")) + sorted(dest.glob("*.csv"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No Parent View file found in {dest}")
|
||||
path = files[-1]
|
||||
|
||||
print(f" ParentView: loading {path} ...")
|
||||
|
||||
if str(path).endswith(".xlsx"):
|
||||
df = pd.read_excel(path)
|
||||
else:
|
||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
|
||||
|
||||
# Normalise URN column
|
||||
urn_col = next((c for c in df.columns if c.strip().upper() == "URN"), None)
|
||||
if not urn_col:
|
||||
raise ValueError(f"URN column not found. Columns: {list(df.columns)[:20]}")
|
||||
df.rename(columns={urn_col: "urn"}, inplace=True)
|
||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
||||
df = df.dropna(subset=["urn"])
|
||||
df["urn"] = df["urn"].astype(int)
|
||||
|
||||
# Try to find total responses column
|
||||
resp_col = next((c for c in df.columns if "total" in c.lower() and "respon" in c.lower()), None)
|
||||
|
||||
inserted = 0
|
||||
today = date.today()
|
||||
|
||||
with get_session() as session:
|
||||
from sqlalchemy import text
|
||||
for _, row in df.iterrows():
|
||||
urn = int(row["urn"])
|
||||
total = int(row[resp_col]) if resp_col and pd.notna(row.get(resp_col)) else None
|
||||
|
||||
# Try to extract % positive per question from wide-format columns
|
||||
# Parent View has numbered questions Q1–Q12 (or Q1–Q14 depending on year)
|
||||
record = {
|
||||
"urn": urn,
|
||||
"survey_date": today,
|
||||
"total_responses": total,
|
||||
"q_happy_pct": _positive_pct(row, "Q1"),
|
||||
"q_safe_pct": _positive_pct(row, "Q2"),
|
||||
"q_behaviour_pct": _positive_pct(row, "Q3"),
|
||||
"q_bullying_pct": _positive_pct(row, "Q4"),
|
||||
"q_communication_pct": _positive_pct(row, "Q5"),
|
||||
"q_progress_pct": _positive_pct(row, "Q7"),
|
||||
"q_teaching_pct": _positive_pct(row, "Q8"),
|
||||
"q_information_pct": _positive_pct(row, "Q9"),
|
||||
"q_curriculum_pct": _positive_pct(row, "Q10"),
|
||||
"q_future_pct": _positive_pct(row, "Q11"),
|
||||
"q_leadership_pct": _positive_pct(row, "Q12"),
|
||||
"q_wellbeing_pct": _positive_pct(row, "Q13"),
|
||||
"q_recommend_pct": _positive_pct(row, "Q14"),
|
||||
"q_sen_pct": None,
|
||||
}
|
||||
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO ofsted_parent_view
|
||||
(urn, survey_date, total_responses,
|
||||
q_happy_pct, q_safe_pct, q_behaviour_pct, q_bullying_pct,
|
||||
q_communication_pct, q_progress_pct, q_teaching_pct,
|
||||
q_information_pct, q_curriculum_pct, q_future_pct,
|
||||
q_leadership_pct, q_wellbeing_pct, q_recommend_pct, q_sen_pct)
|
||||
VALUES
|
||||
(:urn, :survey_date, :total_responses,
|
||||
:q_happy_pct, :q_safe_pct, :q_behaviour_pct, :q_bullying_pct,
|
||||
:q_communication_pct, :q_progress_pct, :q_teaching_pct,
|
||||
:q_information_pct, :q_curriculum_pct, :q_future_pct,
|
||||
:q_leadership_pct, :q_wellbeing_pct, :q_recommend_pct, :q_sen_pct)
|
||||
ON CONFLICT (urn) DO UPDATE SET
|
||||
survey_date = EXCLUDED.survey_date,
|
||||
total_responses = EXCLUDED.total_responses,
|
||||
q_happy_pct = EXCLUDED.q_happy_pct,
|
||||
q_safe_pct = EXCLUDED.q_safe_pct,
|
||||
q_behaviour_pct = EXCLUDED.q_behaviour_pct,
|
||||
q_bullying_pct = EXCLUDED.q_bullying_pct,
|
||||
q_communication_pct = EXCLUDED.q_communication_pct,
|
||||
q_progress_pct = EXCLUDED.q_progress_pct,
|
||||
q_teaching_pct = EXCLUDED.q_teaching_pct,
|
||||
q_information_pct = EXCLUDED.q_information_pct,
|
||||
q_curriculum_pct = EXCLUDED.q_curriculum_pct,
|
||||
q_future_pct = EXCLUDED.q_future_pct,
|
||||
q_leadership_pct = EXCLUDED.q_leadership_pct,
|
||||
q_wellbeing_pct = EXCLUDED.q_wellbeing_pct,
|
||||
q_recommend_pct = EXCLUDED.q_recommend_pct,
|
||||
q_sen_pct = EXCLUDED.q_sen_pct
|
||||
"""),
|
||||
record,
|
||||
)
|
||||
inserted += 1
|
||||
if inserted % 2000 == 0:
|
||||
session.flush()
|
||||
|
||||
print(f" ParentView: upserted {inserted} records")
|
||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
parser.add_argument("--data-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action in ("download", "all"):
|
||||
download(args.data_dir)
|
||||
if args.action in ("load", "all"):
|
||||
load(data_dir=args.data_dir)
|
||||
@@ -1,132 +0,0 @@
|
||||
"""
|
||||
Phonics Screening Check downloader and loader.
|
||||
|
||||
Source: EES publication "phonics-screening-check-and-key-stage-1-assessments-england"
|
||||
Update: Annual (September/October)
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
DEST_DIR = SUPPLEMENTARY_DIR / "phonics"
|
||||
PUBLICATION_SLUG = "phonics-screening-check-and-key-stage-1-assessments-england"
|
||||
|
||||
# Known column names in the phonics CSV (vary by year)
|
||||
COLUMN_MAP = {
|
||||
"URN": "urn",
|
||||
"urn": "urn",
|
||||
# Year 1 pass rate
|
||||
"PPTA1": "year1_phonics_pct", # % meeting expected standard Y1
|
||||
"PPTA1B": "year1_phonics_pct",
|
||||
"PT_MET_PHON_Y1": "year1_phonics_pct",
|
||||
"Y1_MET_EXPECTED_PCT": "year1_phonics_pct",
|
||||
# Year 2 (re-takers)
|
||||
"PPTA2": "year2_phonics_pct",
|
||||
"PT_MET_PHON_Y2": "year2_phonics_pct",
|
||||
"Y2_MET_EXPECTED_PCT": "year2_phonics_pct",
|
||||
# Year label
|
||||
"YEAR": "year",
|
||||
"Year": "year",
|
||||
}
|
||||
|
||||
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", ""}
|
||||
|
||||
|
||||
def download(data_dir: Path | None = None) -> Path:
|
||||
dest = (data_dir / "supplementary" / "phonics") if data_dir else DEST_DIR
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
|
||||
if not url:
|
||||
raise RuntimeError(f"Could not find CSV URL for phonics publication")
|
||||
|
||||
filename = url.split("/")[-1].split("?")[0] or "phonics_latest.csv"
|
||||
return download_csv(url, dest / filename)
|
||||
|
||||
|
||||
def _parse_pct(val) -> float | None:
|
||||
if pd.isna(val):
|
||||
return None
|
||||
s = str(val).strip().upper().replace("%", "")
|
||||
if s in NULL_VALUES:
|
||||
return None
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
||||
if path is None:
|
||||
dest = (data_dir / "supplementary" / "phonics") if data_dir else DEST_DIR
|
||||
files = sorted(dest.glob("*.csv"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No phonics CSV found in {dest}")
|
||||
path = files[-1]
|
||||
|
||||
print(f" Phonics: loading {path} ...")
|
||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
|
||||
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]}")
|
||||
|
||||
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
|
||||
df = df.dropna(subset=["urn"])
|
||||
df["urn"] = df["urn"].astype(int)
|
||||
|
||||
# Infer year from filename if not in data
|
||||
year = None
|
||||
import re
|
||||
m = re.search(r"20(\d{2})", path.stem)
|
||||
if m:
|
||||
year = int("20" + m.group(1))
|
||||
|
||||
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
|
||||
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO phonics (urn, year, year1_phonics_pct, year2_phonics_pct)
|
||||
VALUES (:urn, :year, :y1, :y2)
|
||||
ON CONFLICT (urn, year) DO UPDATE SET
|
||||
year1_phonics_pct = EXCLUDED.year1_phonics_pct,
|
||||
year2_phonics_pct = EXCLUDED.year2_phonics_pct
|
||||
"""),
|
||||
{
|
||||
"urn": urn,
|
||||
"year": row_year,
|
||||
"y1": _parse_pct(row.get("year1_phonics_pct")),
|
||||
"y2": _parse_pct(row.get("year2_phonics_pct")),
|
||||
},
|
||||
)
|
||||
inserted += 1
|
||||
if inserted % 5000 == 0:
|
||||
session.flush()
|
||||
|
||||
print(f" Phonics: upserted {inserted} records")
|
||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
parser.add_argument("--data-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.action in ("download", "all"):
|
||||
download(args.data_dir)
|
||||
if args.action in ("load", "all"):
|
||||
load(data_dir=args.data_dir)
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
SEN (Special Educational Needs) primary need type breakdown.
|
||||
|
||||
Source: EES publication "special-educational-needs-in-england"
|
||||
Update: Annual (September)
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
DEST_DIR = SUPPLEMENTARY_DIR / "sen_detail"
|
||||
PUBLICATION_SLUG = "special-educational-needs-in-england"
|
||||
|
||||
NULL_VALUES = {"SUPP", "NE", "NA", "NP", "NEW", "LOW", "X", ""}
|
||||
|
||||
COLUMN_MAP = {
|
||||
"URN": "urn",
|
||||
"urn": "urn",
|
||||
"YEAR": "year",
|
||||
"Year": "year",
|
||||
# Primary need types — DfE abbreviated codes
|
||||
"PT_SPEECH": "primary_need_speech_pct", # SLCN
|
||||
"PT_ASD": "primary_need_autism_pct", # ASD
|
||||
"PT_MLD": "primary_need_mld_pct", # Moderate learning difficulty
|
||||
"PT_SPLD": "primary_need_spld_pct", # Specific learning difficulty
|
||||
"PT_SEMH": "primary_need_semh_pct", # Social, emotional, mental health
|
||||
"PT_PHYSICAL": "primary_need_physical_pct", # Physical/sensory
|
||||
"PT_OTHER": "primary_need_other_pct",
|
||||
# Alternative naming
|
||||
"SLCN_PCT": "primary_need_speech_pct",
|
||||
"ASD_PCT": "primary_need_autism_pct",
|
||||
"MLD_PCT": "primary_need_mld_pct",
|
||||
"SPLD_PCT": "primary_need_spld_pct",
|
||||
"SEMH_PCT": "primary_need_semh_pct",
|
||||
"PHYSICAL_PCT": "primary_need_physical_pct",
|
||||
"OTHER_PCT": "primary_need_other_pct",
|
||||
}
|
||||
|
||||
|
||||
def download(data_dir: Path | None = None) -> Path:
|
||||
dest = (data_dir / "supplementary" / "sen_detail") if data_dir else DEST_DIR
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
url = get_latest_csv_url(PUBLICATION_SLUG, keyword="school")
|
||||
if not url:
|
||||
url = get_latest_csv_url(PUBLICATION_SLUG)
|
||||
if not url:
|
||||
raise RuntimeError("Could not find CSV URL for SEN publication")
|
||||
|
||||
filename = url.split("/")[-1].split("?")[0] or "sen_latest.csv"
|
||||
return download_csv(url, dest / filename)
|
||||
|
||||
|
||||
def _parse_pct(val) -> float | None:
|
||||
if pd.isna(val):
|
||||
return None
|
||||
s = str(val).strip().upper().replace("%", "")
|
||||
if s in NULL_VALUES:
|
||||
return None
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def load(path: Path | None = None, data_dir: Path | None = None) -> dict:
|
||||
if path is None:
|
||||
dest = (data_dir / "supplementary" / "sen_detail") if data_dir else DEST_DIR
|
||||
files = sorted(dest.glob("*.csv"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No SEN CSV found in {dest}")
|
||||
path = files[-1]
|
||||
|
||||
print(f" SEN Detail: loading {path} ...")
|
||||
df = pd.read_csv(path, encoding="latin-1", low_memory=False)
|
||||
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]}")
|
||||
|
||||
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))
|
||||
|
||||
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
|
||||
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO sen_detail
|
||||
(urn, year, primary_need_speech_pct, primary_need_autism_pct,
|
||||
primary_need_mld_pct, primary_need_spld_pct, primary_need_semh_pct,
|
||||
primary_need_physical_pct, primary_need_other_pct)
|
||||
VALUES (:urn, :year, :speech, :autism, :mld, :spld, :semh, :physical, :other)
|
||||
ON CONFLICT (urn, year) DO UPDATE SET
|
||||
primary_need_speech_pct = EXCLUDED.primary_need_speech_pct,
|
||||
primary_need_autism_pct = EXCLUDED.primary_need_autism_pct,
|
||||
primary_need_mld_pct = EXCLUDED.primary_need_mld_pct,
|
||||
primary_need_spld_pct = EXCLUDED.primary_need_spld_pct,
|
||||
primary_need_semh_pct = EXCLUDED.primary_need_semh_pct,
|
||||
primary_need_physical_pct = EXCLUDED.primary_need_physical_pct,
|
||||
primary_need_other_pct = EXCLUDED.primary_need_other_pct
|
||||
"""),
|
||||
{
|
||||
"urn": urn, "year": row_year,
|
||||
"speech": _parse_pct(row.get("primary_need_speech_pct")),
|
||||
"autism": _parse_pct(row.get("primary_need_autism_pct")),
|
||||
"mld": _parse_pct(row.get("primary_need_mld_pct")),
|
||||
"spld": _parse_pct(row.get("primary_need_spld_pct")),
|
||||
"semh": _parse_pct(row.get("primary_need_semh_pct")),
|
||||
"physical": _parse_pct(row.get("primary_need_physical_pct")),
|
||||
"other": _parse_pct(row.get("primary_need_other_pct")),
|
||||
},
|
||||
)
|
||||
inserted += 1
|
||||
if inserted % 5000 == 0:
|
||||
session.flush()
|
||||
|
||||
print(f" SEN Detail: upserted {inserted} records")
|
||||
return {"inserted": inserted, "updated": 0, "skipped": 0}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--action", choices=["download", "load", "all"], default="all")
|
||||
parser.add_argument("--data-dir", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.action in ("download", "all"):
|
||||
download(args.data_dir)
|
||||
if args.action in ("load", "all"):
|
||||
load(data_dir=args.data_dir)
|
||||
@@ -1,70 +0,0 @@
|
||||
"""
|
||||
Data integrator HTTP server.
|
||||
Kestra calls this server via HTTP tasks to trigger download/load operations.
|
||||
"""
|
||||
import importlib
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
sys.path.insert(0, "/app/scripts")
|
||||
|
||||
app = FastAPI(title="SchoolCompare Data Integrator", version="1.0.0")
|
||||
|
||||
SOURCES = {
|
||||
"ofsted", "gias", "parent_view",
|
||||
"census", "admissions", "sen_detail",
|
||||
"phonics", "idaci", "finance", "ks2",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/run/{source}")
|
||||
def run_source(source: str, action: str = "all"):
|
||||
"""
|
||||
Trigger a data source download and/or load.
|
||||
action: "download" | "load" | "all"
|
||||
"""
|
||||
if source not in SOURCES:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown source '{source}'. Available: {sorted(SOURCES)}")
|
||||
if action not in ("download", "load", "all"):
|
||||
raise HTTPException(status_code=400, detail="action must be 'download', 'load', or 'all'")
|
||||
|
||||
try:
|
||||
mod = importlib.import_module(f"sources.{source}")
|
||||
result = {}
|
||||
|
||||
if action in ("download", "all"):
|
||||
mod.download()
|
||||
|
||||
if action in ("load", "all"):
|
||||
result = mod.load()
|
||||
|
||||
return {"source": source, "action": action, "result": result}
|
||||
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc()
|
||||
raise HTTPException(status_code=500, detail={"error": str(e), "traceback": tb})
|
||||
|
||||
|
||||
@app.post("/run-all")
|
||||
def run_all(action: str = "all"):
|
||||
"""Trigger all sources in sequence."""
|
||||
results = {}
|
||||
for source in sorted(SOURCES):
|
||||
try:
|
||||
mod = importlib.import_module(f"sources.{source}")
|
||||
if action in ("download", "all"):
|
||||
mod.download()
|
||||
if action in ("load", "all"):
|
||||
results[source] = mod.load()
|
||||
except Exception as e:
|
||||
results[source] = {"error": str(e)}
|
||||
return results
|
||||
@@ -58,6 +58,23 @@
|
||||
|
||||
--transition: 0.2s ease;
|
||||
--transition-slow: 0.4s ease;
|
||||
|
||||
/* Phase indicators */
|
||||
--phase-primary: #5b8cbf;
|
||||
--phase-primary-bg: rgba(91, 140, 191, 0.10);
|
||||
--phase-primary-text: #3d6a99;
|
||||
--phase-secondary: #9b6bb0;
|
||||
--phase-secondary-bg: rgba(155, 107, 176, 0.10);
|
||||
--phase-secondary-text: #7a4f93;
|
||||
--phase-all-through: #7a9a6d;
|
||||
--phase-all-through-bg: rgba(122, 154, 109, 0.10);
|
||||
--phase-all-through-text: #5a7a4d;
|
||||
--phase-post16: #c4915e;
|
||||
--phase-post16-bg: rgba(196, 145, 94, 0.10);
|
||||
--phase-post16-text: #9a6d3a;
|
||||
--phase-nursery: #e0a0b0;
|
||||
--phase-nursery-bg: rgba(224, 160, 176, 0.10);
|
||||
--phase-nursery-text: #b06070;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -23,11 +23,11 @@ const playfairDisplay = Playfair_Display({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'SchoolCompare | Compare Primary School Performance',
|
||||
default: 'SchoolCompare | Compare School Performance',
|
||||
template: '%s | SchoolCompare',
|
||||
},
|
||||
description: 'Compare primary school KS2 performance across England',
|
||||
keywords: 'school comparison, KS2 results, primary school performance, England schools, SATs results',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||
authors: [{ name: 'SchoolCompare' }],
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
@@ -37,15 +37,15 @@ export const metadata: Metadata = {
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'SchoolCompare | Compare Primary School Performance',
|
||||
description: 'Compare primary school KS2 performance across England',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
url: 'https://schoolcompare.co.uk',
|
||||
siteName: 'SchoolCompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: 'SchoolCompare | Compare Primary School Performance',
|
||||
description: 'Compare primary school KS2 performance across England',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,15 +11,20 @@ interface HomePageProps {
|
||||
search?: string;
|
||||
local_authority?: string;
|
||||
school_type?: string;
|
||||
phase?: string;
|
||||
page?: string;
|
||||
postcode?: string;
|
||||
radius?: string;
|
||||
sort?: string;
|
||||
gender?: string;
|
||||
admissions_policy?: string;
|
||||
has_sixth_form?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Home',
|
||||
description: 'Search and compare primary school KS2 performance across England',
|
||||
description: 'Search and compare school performance across England',
|
||||
};
|
||||
|
||||
// Force dynamic rendering (no static generation at build time)
|
||||
@@ -38,7 +43,11 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
params.search ||
|
||||
params.local_authority ||
|
||||
params.school_type ||
|
||||
params.postcode
|
||||
params.phase ||
|
||||
params.postcode ||
|
||||
params.gender ||
|
||||
params.admissions_policy ||
|
||||
params.has_sixth_form
|
||||
);
|
||||
|
||||
// Fetch data on server with error handling
|
||||
@@ -52,10 +61,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
search: params.search,
|
||||
local_authority: params.local_authority,
|
||||
school_type: params.school_type,
|
||||
phase: params.phase,
|
||||
postcode: params.postcode,
|
||||
radius,
|
||||
page,
|
||||
page_size: 50,
|
||||
gender: params.gender,
|
||||
admissions_policy: params.admissions_policy,
|
||||
has_sixth_form: params.has_sixth_form,
|
||||
});
|
||||
} else {
|
||||
// Empty state by default
|
||||
@@ -65,7 +78,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
return (
|
||||
<HomeView
|
||||
initialSchools={schoolsData}
|
||||
filters={filtersData || { local_authorities: [], school_types: [], years: [] }}
|
||||
filters={filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||
totalSchools={dataInfo?.total_schools ?? null}
|
||||
/>
|
||||
);
|
||||
@@ -76,7 +89,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
return (
|
||||
<HomeView
|
||||
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
||||
filters={{ local_authorities: [], school_types: [], years: [] }}
|
||||
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||
totalSchools={null}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,22 +12,24 @@ interface RankingsPageProps {
|
||||
metric?: string;
|
||||
local_authority?: string;
|
||||
year?: string;
|
||||
phase?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'School Rankings',
|
||||
description: 'Top-ranked primary schools by KS2 performance across England',
|
||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, school league tables',
|
||||
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||
};
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
||||
const { metric: metricParam, local_authority, year: yearParam } = await searchParams;
|
||||
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
|
||||
|
||||
const metric = metricParam || 'rwm_expected_pct';
|
||||
const phase = phaseParam || 'primary';
|
||||
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||
const year = yearParam ? parseInt(yearParam) : undefined;
|
||||
|
||||
// Fetch rankings data with error handling
|
||||
@@ -38,6 +40,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
||||
local_authority,
|
||||
year,
|
||||
limit: 100,
|
||||
phase,
|
||||
}),
|
||||
fetchFilters(),
|
||||
fetchMetrics(),
|
||||
@@ -49,11 +52,12 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
||||
return (
|
||||
<RankingsView
|
||||
rankings={rankingsResponse?.rankings || []}
|
||||
filters={filtersResponse || { local_authorities: [], school_types: [], years: [] }}
|
||||
filters={filtersResponse || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||
metrics={metricsArray}
|
||||
selectedMetric={metric}
|
||||
selectedArea={local_authority}
|
||||
selectedYear={year}
|
||||
selectedPhase={phase}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -63,11 +67,12 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
||||
return (
|
||||
<RankingsView
|
||||
rankings={[]}
|
||||
filters={{ local_authorities: [], school_types: [], years: [] }}
|
||||
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||
metrics={[]}
|
||||
selectedMetric={metric}
|
||||
selectedArea={local_authority}
|
||||
selectedYear={year}
|
||||
selectedPhase={phase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
180
nextjs-app/app/school/[slug]/page.tsx
Normal file
180
nextjs-app/app/school/[slug]/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Individual School Page (SSR)
|
||||
* Dynamic route for school details with full SEO optimization
|
||||
* URL format: /school/138267-school-name-here
|
||||
*/
|
||||
|
||||
import { fetchSchoolDetails } from '@/lib/api';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface SchoolPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const urn = parseSchoolSlug(slug);
|
||||
|
||||
if (!urn || urn < 100000 || urn > 999999) {
|
||||
return {
|
||||
title: 'School Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchSchoolDetails(urn);
|
||||
const { school_info } = data;
|
||||
|
||||
const canonicalPath = schoolUrl(urn, school_info.school_name);
|
||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||
const isAllThrough = phaseStr === 'all-through';
|
||||
const isSecondary = !isAllThrough && (
|
||||
phaseStr.includes('secondary')
|
||||
|| (data.yearly_data ?? []).some((d: any) => d.attainment_8_score != null)
|
||||
);
|
||||
const la = school_info.local_authority ? ` in ${school_info.local_authority}` : '';
|
||||
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
||||
const description = isAllThrough
|
||||
? `View KS2 SATs and GCSE results for ${school_info.school_name}${la}. All-through school covering primary and secondary education.`
|
||||
: isSecondary
|
||||
? `View GCSE results, Attainment 8, Progress 8 and school statistics for ${school_info.school_name}${la}.`
|
||||
: `View KS2 performance data, results, and statistics for ${school_info.school_name}${la}. Compare reading, writing, and maths results.`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords: isAllThrough
|
||||
? `${school_info.school_name}, KS2 results, GCSE results, all-through school, ${school_info.local_authority}, SATs, Attainment 8`
|
||||
: isSecondary
|
||||
? `${school_info.school_name}, GCSE results, secondary school, ${school_info.local_authority}, Attainment 8, Progress 8`
|
||||
: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
siteName: 'SchoolCompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title,
|
||||
description,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: 'School Not Found',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
const { slug } = await params;
|
||||
const urn = parseSchoolSlug(slug);
|
||||
|
||||
// Validate URN format
|
||||
if (!urn || urn < 100000 || urn > 999999) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch school data
|
||||
let data;
|
||||
try {
|
||||
data = await fetchSchoolDetails(urn);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
if (slug !== canonicalSlug) {
|
||||
redirect(`/school/${canonicalSlug}`);
|
||||
}
|
||||
|
||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||
const isAllThrough = phaseStr === 'all-through';
|
||||
// All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections).
|
||||
// SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data.
|
||||
const isSecondary = !isAllThrough && (
|
||||
phaseStr.includes('secondary')
|
||||
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
||||
);
|
||||
|
||||
// Generate JSON-LD structured data for SEO
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'EducationalOrganization',
|
||||
name: school_info.school_name,
|
||||
identifier: school_info.urn.toString(),
|
||||
...(school_info.address && {
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: school_info.address,
|
||||
addressLocality: school_info.local_authority || undefined,
|
||||
postalCode: school_info.postcode || undefined,
|
||||
addressCountry: 'GB',
|
||||
},
|
||||
}),
|
||||
...(school_info.latitude && school_info.longitude && {
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: school_info.latitude,
|
||||
longitude: school_info.longitude,
|
||||
},
|
||||
}),
|
||||
...(school_info.school_type && {
|
||||
additionalType: school_info.school_type,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
{isSecondary ? (
|
||||
<SecondarySchoolDetailView
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
parentView={parent_view ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
senDetail={sen_detail ?? null}
|
||||
phonics={phonics ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
/>
|
||||
) : (
|
||||
<SchoolDetailView
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
parentView={parent_view ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
senDetail={sen_detail ?? null}
|
||||
phonics={phonics ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Individual School Page (SSR)
|
||||
* Dynamic route for school details with full SEO optimization
|
||||
*/
|
||||
|
||||
import { fetchSchoolDetails } from '@/lib/api';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface SchoolPageProps {
|
||||
params: Promise<{ urn: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
|
||||
const { urn: urnString } = await params;
|
||||
const urn = parseInt(urnString);
|
||||
|
||||
if (isNaN(urn) || urn < 100000 || urn > 999999) {
|
||||
return {
|
||||
title: 'School Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchSchoolDetails(urn);
|
||||
const { school_info } = data;
|
||||
|
||||
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
||||
const description = `View KS2 performance data, results, and statistics for ${school_info.school_name}${school_info.local_authority ? ` in ${school_info.local_authority}` : ''}. Compare reading, writing, and maths results.`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `https://schoolcompare.co.uk/school/${urn}`,
|
||||
siteName: 'SchoolCompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title,
|
||||
description,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `https://schoolcompare.co.uk/school/${urn}`,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: 'School Not Found',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
const { urn: urnString } = await params;
|
||||
const urn = parseInt(urnString);
|
||||
|
||||
// Validate URN format
|
||||
if (isNaN(urn) || urn < 100000 || urn > 999999) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch school data
|
||||
let data;
|
||||
try {
|
||||
data = await fetchSchoolDetails(urn);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
|
||||
|
||||
// Generate JSON-LD structured data for SEO
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'EducationalOrganization',
|
||||
name: school_info.school_name,
|
||||
identifier: school_info.urn.toString(),
|
||||
...(school_info.address && {
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: school_info.address,
|
||||
addressLocality: school_info.local_authority || undefined,
|
||||
postalCode: school_info.postcode || undefined,
|
||||
addressCountry: 'GB',
|
||||
},
|
||||
}),
|
||||
...(school_info.latitude && school_info.longitude && {
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: school_info.latitude,
|
||||
longitude: school_info.longitude,
|
||||
},
|
||||
}),
|
||||
...(school_info.school_type && {
|
||||
additionalType: school_info.school_type,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<SchoolDetailView
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
parentView={parent_view ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
senDetail={sen_detail ?? null}
|
||||
phonics={phonics ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Dynamic Sitemap Generation
|
||||
* Generates sitemap with all school pages and main routes
|
||||
*/
|
||||
|
||||
import { MetadataRoute } from 'next';
|
||||
import { fetchSchools } from '@/lib/api';
|
||||
|
||||
const BASE_URL = 'https://schoolcompare.co.uk';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// Static pages
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: BASE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/compare`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/rankings`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
},
|
||||
];
|
||||
|
||||
// Fetch all schools (in batches if necessary)
|
||||
try {
|
||||
const schoolsData = await fetchSchools({
|
||||
page: 1,
|
||||
page_size: 10000, // Fetch all schools
|
||||
});
|
||||
|
||||
const schoolPages: MetadataRoute.Sitemap = schoolsData.schools.map((school) => ({
|
||||
url: `${BASE_URL}/school/${school.urn}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
}));
|
||||
|
||||
return [...staticPages, ...schoolPages];
|
||||
} catch (error) {
|
||||
console.error('Failed to generate sitemap:', error);
|
||||
// Return just static pages if school fetch fails
|
||||
return staticPages;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ChartOptions,
|
||||
} from 'chart.js';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
import { CHART_COLORS } from '@/lib/utils';
|
||||
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
@@ -68,7 +68,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
||||
});
|
||||
|
||||
const chartData = {
|
||||
labels: years.map(String),
|
||||
labels: years.map(formatAcademicYear),
|
||||
datasets,
|
||||
};
|
||||
|
||||
|
||||
@@ -23,18 +23,12 @@
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-accent, #1a1612);
|
||||
color: var(--text-inverse, #faf7f2);
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
color: var(--text-primary, #2c2420);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(26, 22, 18, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.toastInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 8px 32px rgba(44, 36, 32, 0.18), 0 2px 8px rgba(44, 36, 32, 0.08);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.toastBadge {
|
||||
@@ -48,38 +42,7 @@
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toastText {
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toastActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
|
||||
.btnCompare {
|
||||
background: white;
|
||||
color: var(--bg-accent, #1a1612);
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btnCompare:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toastHeader {
|
||||
@@ -93,10 +56,19 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toastTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(250, 247, 242, 0.6);
|
||||
color: var(--text-muted, #8a7a72);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
@@ -107,16 +79,7 @@
|
||||
}
|
||||
|
||||
.collapseBtn:hover {
|
||||
color: var(--text-inverse, #faf7f2);
|
||||
}
|
||||
|
||||
.toastTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-inverse, #faf7f2);
|
||||
color: var(--text-primary, #2c2420);
|
||||
}
|
||||
|
||||
.schoolList {
|
||||
@@ -124,8 +87,6 @@
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.schoolItem {
|
||||
@@ -133,14 +94,14 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-inverse, #faf7f2);
|
||||
color: var(--text-primary, #2c2420);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -150,7 +111,7 @@
|
||||
.removeSchoolBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(250, 247, 242, 0.5);
|
||||
color: var(--text-muted, #8a7a72);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
@@ -160,7 +121,50 @@
|
||||
}
|
||||
|
||||
.removeSchoolBtn:hover {
|
||||
color: var(--text-inverse, #faf7f2);
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.toastActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-color, #e8ddd4);
|
||||
}
|
||||
|
||||
.btnClearAll {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.25rem;
|
||||
transition: color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btnClearAll:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.btnCompare {
|
||||
flex: 1;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btnCompare:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--accent-coral-dark, #c9614a);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import styles from './ComparisonToast.module.css';
|
||||
export function ComparisonToast() {
|
||||
const { selectedSchools, clearAll, removeSchool } = useComparison();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,7 +62,7 @@ export function ComparisonToast() {
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.toastActions}>
|
||||
<button onClick={clearAll} className="btn btn-tertiary btn-sm" style={{ color: 'rgba(250,247,242,0.7)', borderColor: 'rgba(255,255,255,0.15)' }}>Clear all</button>
|
||||
<button onClick={clearAll} className={styles.btnClearAll}>Clear all</button>
|
||||
<Link href="/compare" className={styles.btnCompare}>Compare Now</Link>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -31,6 +31,47 @@
|
||||
}
|
||||
|
||||
|
||||
/* Phase Tabs */
|
||||
.phaseTabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.phaseTab {
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
/* Metric Selector */
|
||||
.metricSelector {
|
||||
background: var(--bg-card, white);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ComparisonView Component
|
||||
* Client-side comparison interface with charts and tables
|
||||
* Client-side comparison interface with phase tabs, charts, and tables
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -12,11 +12,30 @@ import { ComparisonChart } from './ComparisonChart';
|
||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||
import type { ComparisonData, MetricDefinition } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
|
||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
||||
import { fetchComparison } from '@/lib/api';
|
||||
import styles from './ComparisonView.module.css';
|
||||
|
||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||
const SECONDARY_CATEGORIES = ['gcse'];
|
||||
|
||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'Expected Standard', category: 'expected' },
|
||||
{ label: 'Higher Standard', category: 'higher' },
|
||||
{ label: 'Progress Scores', category: 'progress' },
|
||||
{ label: 'Average Scores', category: 'average' },
|
||||
{ label: 'Gender Performance', category: 'gender' },
|
||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||
{ label: 'School Context', category: 'context' },
|
||||
{ label: 'Absence', category: 'absence' },
|
||||
{ label: '3-Year Trends', category: 'trends' },
|
||||
];
|
||||
|
||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'GCSE Performance', category: 'gcse' },
|
||||
];
|
||||
|
||||
interface ComparisonViewProps {
|
||||
initialData: Record<string, ComparisonData> | null;
|
||||
initialUrns: number[];
|
||||
@@ -39,6 +58,7 @@ export function ComparisonView({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [comparisonData, setComparisonData] = useState(initialData);
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||
|
||||
// Seed context from initialData when component mounts and localStorage is empty
|
||||
useEffect(() => {
|
||||
@@ -84,6 +104,37 @@ export function ComparisonView({
|
||||
}
|
||||
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
||||
|
||||
// Classify schools by phase using comparison data
|
||||
const classifySchool = (school: School): 'primary' | 'secondary' => {
|
||||
const info = comparisonData?.[school.urn]?.school_info;
|
||||
if (info?.attainment_8_score != null) return 'secondary';
|
||||
if (info?.rwm_expected_pct != null) return 'primary';
|
||||
// Fallback: check yearly data
|
||||
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
|
||||
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
|
||||
return 'primary';
|
||||
};
|
||||
|
||||
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
||||
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
||||
|
||||
// Auto-select tab with more schools
|
||||
useEffect(() => {
|
||||
if (comparisonData && selectedSchools.length > 0) {
|
||||
if (secondarySchools.length > primarySchools.length) {
|
||||
setComparePhase('secondary');
|
||||
} else {
|
||||
setComparePhase('primary');
|
||||
}
|
||||
}
|
||||
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||
setComparePhase(phase);
|
||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
setSelectedMetric(defaultMetric);
|
||||
};
|
||||
|
||||
const handleMetricChange = (metric: string) => {
|
||||
setSelectedMetric(metric);
|
||||
};
|
||||
@@ -100,6 +151,12 @@ export function ComparisonView({
|
||||
} catch { /* fallback: do nothing */ }
|
||||
};
|
||||
|
||||
const isPrimary = comparePhase === 'primary';
|
||||
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
||||
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
||||
|
||||
// Get metric definition
|
||||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
||||
const metricLabel = currentMetricDef?.label || selectedMetric;
|
||||
@@ -129,10 +186,20 @@ export function ComparisonView({
|
||||
);
|
||||
}
|
||||
|
||||
// Build filtered comparison data for active phase
|
||||
const activeComparisonData: Record<string, ComparisonData> = {};
|
||||
if (comparisonData) {
|
||||
activeSchools.forEach(s => {
|
||||
if (comparisonData[s.urn]) {
|
||||
activeComparisonData[s.urn] = comparisonData[s.urn];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get years for table
|
||||
const years =
|
||||
comparisonData && Object.keys(comparisonData).length > 0
|
||||
? comparisonData[Object.keys(comparisonData)[0]].yearly_data.map((d) => d.year)
|
||||
Object.keys(activeComparisonData).length > 0
|
||||
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
||||
: [];
|
||||
|
||||
return (
|
||||
@@ -158,6 +225,33 @@ export function ComparisonView({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Phase Tabs */}
|
||||
<div className={styles.phaseTabs}>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('primary')}
|
||||
>
|
||||
Primary ({primarySchools.length})
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('secondary')}
|
||||
>
|
||||
Secondary ({secondarySchools.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeSchools.length === 0 ? (
|
||||
<EmptyState
|
||||
title={`No ${comparePhase} schools in your comparison`}
|
||||
message={`Add ${comparePhase} schools from search results to compare them here.`}
|
||||
action={{
|
||||
label: '+ Add Schools',
|
||||
onClick: () => setIsModalOpen(true),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Metric Selector */}
|
||||
<section className={styles.metricSelector}>
|
||||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
||||
@@ -169,46 +263,17 @@ export function ComparisonView({
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
className={styles.metricSelect}
|
||||
>
|
||||
<optgroup label="Expected Standard">
|
||||
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
{metrics.filter(m => m.category === 'progress').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
{metrics.filter(m => m.category === 'average').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
{metrics.filter(m => m.category === 'gender').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="School Context">
|
||||
{metrics.filter(m => m.category === 'context').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
{metrics.filter(m => m.category === '3yr').map((metric) => (
|
||||
{optgroups.map(({ label, category }) => {
|
||||
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||
if (groupMetrics.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={category} label={label}>
|
||||
{groupMetrics.map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{currentMetricDef?.description && (
|
||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||
@@ -218,14 +283,14 @@ export function ComparisonView({
|
||||
{/* Progress score explanation */}
|
||||
{selectedMetric.includes('progress') && (
|
||||
<p className={styles.progressNote}>
|
||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* School Cards */}
|
||||
<section className={styles.schoolsSection}>
|
||||
<div className={styles.schoolsGrid}>
|
||||
{selectedSchools.map((school, index) => (
|
||||
{activeSchools.map((school, index) => (
|
||||
<div
|
||||
key={school.urn}
|
||||
className={styles.schoolCard}
|
||||
@@ -240,7 +305,7 @@ export function ComparisonView({
|
||||
×
|
||||
</button>
|
||||
<h2 className={styles.schoolName}>
|
||||
<a href={`/school/${school.urn}`}>{school.school_name}</a>
|
||||
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
||||
</h2>
|
||||
<div className={styles.schoolMeta}>
|
||||
{school.local_authority && (
|
||||
@@ -252,7 +317,7 @@ export function ComparisonView({
|
||||
</div>
|
||||
|
||||
{/* Latest metric value */}
|
||||
{comparisonData && comparisonData[school.urn] && (
|
||||
{activeComparisonData[school.urn] && (
|
||||
<div className={styles.latestValue}>
|
||||
<div className={styles.latestLabel}>{metricLabel}</div>
|
||||
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_COLORS.length] }}>
|
||||
@@ -268,7 +333,7 @@ export function ComparisonView({
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const yearlyData = comparisonData[school.urn].yearly_data;
|
||||
const yearlyData = activeComparisonData[school.urn].yearly_data;
|
||||
if (yearlyData.length === 0) return '-';
|
||||
|
||||
const latestData = yearlyData[yearlyData.length - 1];
|
||||
@@ -276,7 +341,6 @@ export function ComparisonView({
|
||||
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
// Format based on metric type
|
||||
if (selectedMetric.includes('progress')) {
|
||||
return formatProgress(value as number);
|
||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||
@@ -294,25 +358,25 @@ export function ComparisonView({
|
||||
</section>
|
||||
|
||||
{/* Comparison Chart */}
|
||||
{comparisonData && Object.keys(comparisonData).length > 0 ? (
|
||||
{Object.keys(activeComparisonData).length > 0 ? (
|
||||
<section className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
||||
<div className={styles.chartContainer}>
|
||||
<ComparisonChart
|
||||
comparisonData={comparisonData}
|
||||
comparisonData={activeComparisonData}
|
||||
metric={selectedMetric}
|
||||
metricLabel={metricLabel}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : selectedSchools.length > 0 ? (
|
||||
) : activeSchools.length > 0 ? (
|
||||
<section className={styles.chartSection}>
|
||||
<LoadingSkeleton type="list" />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Comparison Table */}
|
||||
{comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && (
|
||||
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
||||
<section className={styles.tableSection}>
|
||||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
||||
<div className={styles.tableWrapper}>
|
||||
@@ -320,7 +384,7 @@ export function ComparisonView({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
{selectedSchools.map((school) => (
|
||||
{activeSchools.map((school) => (
|
||||
<th key={school.urn}>{school.school_name}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -328,9 +392,9 @@ export function ComparisonView({
|
||||
<tbody>
|
||||
{years.map((year) => (
|
||||
<tr key={year}>
|
||||
<td className={styles.yearCell}>{year}</td>
|
||||
{selectedSchools.map((school) => {
|
||||
const schoolData = comparisonData[school.urn];
|
||||
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
||||
{activeSchools.map((school) => {
|
||||
const schoolData = activeComparisonData[school.urn];
|
||||
if (!schoolData) return <td key={school.urn}>-</td>;
|
||||
|
||||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
||||
@@ -342,7 +406,6 @@ export function ComparisonView({
|
||||
return <td key={school.urn}>-</td>;
|
||||
}
|
||||
|
||||
// Format based on metric type
|
||||
let displayValue: string;
|
||||
if (selectedMetric.includes('progress')) {
|
||||
displayValue = formatProgress(value as number);
|
||||
@@ -361,6 +424,8 @@ export function ComparisonView({
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* School Search Modal */}
|
||||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||
|
||||
@@ -32,8 +32,12 @@
|
||||
padding: 1.25rem 2.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.omniBoxContainer {
|
||||
@@ -84,30 +88,49 @@
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card, white);
|
||||
min-width: 180px;
|
||||
padding: 0.625rem 2.25rem 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-card, white);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.875rem center;
|
||||
background-size: 10px 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
color: var(--text-primary, #1a1612);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -130,6 +153,19 @@
|
||||
.filterSelect {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.radiusWrapper {
|
||||
@@ -140,17 +176,104 @@
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.radiusSelect {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e0ddd8);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
/* ── Controls row (radius + phase + advanced toggle) ─── */
|
||||
.controlsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.875rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.radiusControl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Pill-style inline filter controls (radius + phase) */
|
||||
.controlSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding: 0.4rem 2rem 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-card, white);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.65rem center;
|
||||
background-size: 10px 6px;
|
||||
color: var(--text-primary, #1a1612);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controlSelect:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
}
|
||||
|
||||
.controlSelect:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ─────────────────────────── */
|
||||
|
||||
.advancedToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: none;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.advancedToggle:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
.chevronUp {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 3.5px solid transparent;
|
||||
border-right: 3.5px solid transparent;
|
||||
}
|
||||
|
||||
.chevronDown {
|
||||
border-top: 4.5px solid currentColor;
|
||||
}
|
||||
|
||||
.chevronUp {
|
||||
border-bottom: 4.5px solid currentColor;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
import { useState, useCallback, useTransition, useRef, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
import { isValidPostcode } from '@/lib/utils';
|
||||
import type { Filters } from '@/lib/types';
|
||||
import type { Filters, ResultFilters } from '@/lib/types';
|
||||
import styles from './FilterBar.module.css';
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: Filters;
|
||||
isHero?: boolean;
|
||||
resultFilters?: ResultFilters;
|
||||
}
|
||||
|
||||
export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -27,10 +28,23 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
|
||||
const currentLA = searchParams.get('local_authority') || '';
|
||||
const currentType = searchParams.get('school_type') || '';
|
||||
const currentPhase = searchParams.get('phase') || '';
|
||||
const currentGender = searchParams.get('gender') || '';
|
||||
const currentAdmissionsPolicy = searchParams.get('admissions_policy') || '';
|
||||
const currentHasSixthForm = searchParams.get('has_sixth_form') || '';
|
||||
|
||||
// Count active dropdown filters (not search/postcode, not phase since it's always visible)
|
||||
const activeDropdownFilters = [currentLA, currentType, currentGender, currentAdmissionsPolicy, currentHasSixthForm].filter(Boolean);
|
||||
const hasActiveDropdownFilters = activeDropdownFilters.length > 0;
|
||||
const [filtersOpen, setFiltersOpen] = useState(hasActiveDropdownFilters);
|
||||
|
||||
// Auto-open if filters become active (e.g. URL change)
|
||||
useEffect(() => {
|
||||
if (hasActiveDropdownFilters) setFiltersOpen(true);
|
||||
}, [hasActiveDropdownFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Focus search on '/' or Ctrl+K, but not when typing in an input
|
||||
if ((e.key === '/' || (e.key === 'k' && (e.ctrlKey || e.metaKey))) &&
|
||||
document.activeElement?.tagName !== 'INPUT' &&
|
||||
document.activeElement?.tagName !== 'TEXTAREA' &&
|
||||
@@ -86,7 +100,16 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters = currentSearch || currentLA || currentType || currentPostcode;
|
||||
const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode || currentGender || currentAdmissionsPolicy || currentHasSixthForm;
|
||||
|
||||
// Use result-scoped filter values when available, fall back to global
|
||||
const laOptions = resultFilters?.local_authorities ?? filters.local_authorities;
|
||||
const typeOptions = resultFilters?.school_types ?? filters.school_types;
|
||||
const phaseOptions = resultFilters?.phases ?? filters.phases ?? [];
|
||||
const genderOptions = resultFilters?.genders ?? filters.genders ?? [];
|
||||
const admissionsPolicyOptions = resultFilters?.admissions_policies ?? filters.admissions_policies ?? [];
|
||||
|
||||
const isSecondaryMode = currentPhase === 'secondary' || genderOptions.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
|
||||
@@ -104,25 +127,59 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
{isPending ? <div className={styles.spinner}></div> : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{!isHero && (
|
||||
<>
|
||||
<div className={styles.controlsRow}>
|
||||
{currentPostcode && (
|
||||
<div className={styles.radiusWrapper}>
|
||||
<div className={styles.radiusControl}>
|
||||
<label className={styles.radiusLabel}>Within:</label>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={e => updateURL({ radius: e.target.value })}
|
||||
className={styles.radiusSelect}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="0.5">0.5 miles</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
<option value="10">10 miles</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{phaseOptions.length > 0 && (
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange('phase', e.target.value)}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.advancedToggle}
|
||||
onClick={() => setFiltersOpen(v => !v)}
|
||||
>
|
||||
Advanced{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
|
||||
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
|
||||
</button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className={styles.filters}>
|
||||
<select
|
||||
value={currentLA}
|
||||
@@ -131,10 +188,8 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{filters.local_authorities.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>{la}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -145,19 +200,57 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{filters.school_types.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
|
||||
Clear Filters
|
||||
</button>
|
||||
{isSecondaryMode && (
|
||||
<>
|
||||
{genderOptions.length > 0 && (
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) => handleFilterChange('gender', e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>{g}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) => handleFilterChange('has_sixth_form', e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form (11-18)</option>
|
||||
<option value="no">Without sixth form (11-16)</option>
|
||||
</select>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function Footer() {
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.title}>SchoolCompare</h3>
|
||||
<p className={styles.description}>
|
||||
Compare primary schools across England.
|
||||
Compare primary and secondary schools across England.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,33 +33,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.locationBannerWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.locationBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-teal-bg);
|
||||
border: 1px solid rgba(45, 125, 125, 0.25);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.locationIcon {
|
||||
font-size: 1.25rem;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
/* View Toggle */
|
||||
.viewToggle {
|
||||
display: flex;
|
||||
@@ -111,7 +84,9 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 1rem;
|
||||
height: 480px;
|
||||
height: calc(100vh - 280px);
|
||||
min-height: 520px;
|
||||
max-height: 800px;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
@@ -308,16 +283,14 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.locationBannerWrapper {
|
||||
.resultsHeader {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin-bottom: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.locationBanner {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 6px;
|
||||
.resultsHeaderActions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.viewToggle {
|
||||
@@ -509,6 +482,13 @@
|
||||
padding: 0 0 1rem;
|
||||
}
|
||||
|
||||
.resultsHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e0ddd8);
|
||||
@@ -549,3 +529,21 @@
|
||||
.chipRemove:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loadMoreSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.loadMoreCount {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loadMoreButton {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { SchoolRow } from './SchoolRow';
|
||||
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { Pagination } from './Pagination';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
import { schoolUrl } from '@/lib/utils';
|
||||
import styles from './HomeView.module.css';
|
||||
|
||||
interface HomeViewProps {
|
||||
@@ -24,23 +26,94 @@ interface HomeViewProps {
|
||||
|
||||
export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||||
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
||||
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<string>('default');
|
||||
const sortOrder = searchParams.get('sort') || 'default';
|
||||
const [allSchools, setAllSchools] = useState<School[]>(initialSchools.schools);
|
||||
const [currentPage, setCurrentPage] = useState(initialSchools.page);
|
||||
const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [laAverages, setLaAverages] = useState<Record<string, number>>({});
|
||||
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
||||
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
||||
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||
|
||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||
const isLocationSearch = !!searchParams.get('postcode');
|
||||
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
|
||||
const currentPhase = searchParams.get('phase') || '';
|
||||
const secondaryCount = allSchools.filter(s => s.attainment_8_score != null).length;
|
||||
const primaryCount = allSchools.filter(s => s.rwm_expected_pct != null).length;
|
||||
const isSecondaryView = currentPhase.toLowerCase().includes('secondary')
|
||||
|| (!currentPhase && secondaryCount > primaryCount);
|
||||
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
|
||||
|
||||
// Reset pagination state when search params change
|
||||
useEffect(() => {
|
||||
const newParamsStr = searchParams.toString();
|
||||
if (newParamsStr !== prevSearchParamsRef.current) {
|
||||
prevSearchParamsRef.current = newParamsStr;
|
||||
setAllSchools(initialSchools.schools);
|
||||
setCurrentPage(initialSchools.page);
|
||||
setHasMore(initialSchools.total_pages > 1);
|
||||
setMapSchools([]);
|
||||
}
|
||||
}, [searchParams, initialSchools]);
|
||||
|
||||
// Close bottom sheet if we change views or search
|
||||
useEffect(() => {
|
||||
setSelectedMapSchool(null);
|
||||
}, [resultsView, searchParams]);
|
||||
|
||||
const sortedSchools = [...initialSchools.schools].sort((a, b) => {
|
||||
// Fetch all schools within radius when map view is active
|
||||
useEffect(() => {
|
||||
if (resultsView !== 'map' || !isLocationSearch) return;
|
||||
setIsLoadingMap(true);
|
||||
const params: Record<string, any> = {};
|
||||
searchParams.forEach((value, key) => { params[key] = value; });
|
||||
params.page = 1;
|
||||
params.page_size = 500;
|
||||
fetchSchools(params, { cache: 'no-store' })
|
||||
.then(r => setMapSchools(r.schools))
|
||||
.catch(() => setMapSchools(initialSchools.schools))
|
||||
.finally(() => setIsLoadingMap(false));
|
||||
}, [resultsView, searchParams]);
|
||||
|
||||
// Fetch LA averages when secondary or mixed schools are visible
|
||||
useEffect(() => {
|
||||
if (!isSecondaryView && !isMixedView) return;
|
||||
fetchLAaverages({ cache: 'force-cache' })
|
||||
.then(data => setLaAverages(data.secondary.attainment_8_by_la))
|
||||
.catch(() => {});
|
||||
}, [isSecondaryView, isMixedView]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const params: Record<string, any> = {};
|
||||
searchParams.forEach((value, key) => { params[key] = value; });
|
||||
params.page = currentPage + 1;
|
||||
params.page_size = initialSchools.page_size;
|
||||
const response = await fetchSchools(params, { cache: 'no-store' });
|
||||
setAllSchools(prev => [...prev, ...response.schools]);
|
||||
setCurrentPage(response.page);
|
||||
setHasMore(response.page < response.total_pages);
|
||||
} catch {
|
||||
// silently ignore
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedSchools = [...allSchools].sort((a, b) => {
|
||||
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
||||
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
||||
if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity);
|
||||
if (sortOrder === 'att8_asc') return (a.attainment_8_score ?? Infinity) - (b.attainment_8_score ?? Infinity);
|
||||
if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity);
|
||||
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
||||
return 0;
|
||||
@@ -51,20 +124,21 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
{/* Combined Hero + Search and Filters */}
|
||||
{!isSearchActive && (
|
||||
<div className={styles.heroSection}>
|
||||
<h1 className={styles.heroTitle}>Compare Primary School Performance</h1>
|
||||
<p className={styles.heroDescription}>Search and compare KS2 results for thousands of schools across England</p>
|
||||
<h1 className={styles.heroTitle}>Find Local Schools</h1>
|
||||
<p className={styles.heroDescription}>Compare school results (SATs and GCSE), for thousands of schools across England</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={!isSearchActive}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
/>
|
||||
|
||||
{/* Discovery section shown on landing page before any search */}
|
||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||
<div className={styles.discoverySection}>
|
||||
{totalSchools && <p className={styles.discoveryCount}><strong>{totalSchools.toLocaleString()}+</strong> primary schools across England</p>}
|
||||
{totalSchools && <p className={styles.discoveryCount}><strong>{totalSchools.toLocaleString()}+</strong> primary and secondary schools across England</p>}
|
||||
<p className={styles.discoveryHints}>Try searching for a school name, or enter a postcode to find schools near you.</p>
|
||||
<div className={styles.quickSearches}>
|
||||
<span className={styles.quickSearchLabel}>Quick searches:</span>
|
||||
@@ -75,16 +149,27 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location Info Banner with View Toggle */}
|
||||
{isLocationSearch && initialSchools.location_info && (
|
||||
<div className={styles.locationBannerWrapper}>
|
||||
<div className={styles.locationBanner}>
|
||||
<span>
|
||||
Showing schools within {(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of{' '}
|
||||
<strong>{initialSchools.location_info.postcode}</strong>
|
||||
</span>
|
||||
{/* Results Section */}
|
||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>Featured Schools</h2>
|
||||
<p className={styles.sectionDescription}>
|
||||
Explore schools from across England
|
||||
</p>
|
||||
</div>
|
||||
{initialSchools.schools.length > 0 && (
|
||||
)}
|
||||
|
||||
{hasSearch && (
|
||||
<div className={styles.resultsHeader}>
|
||||
<h2 aria-live="polite" aria-atomic="true">
|
||||
{isLocationSearch && initialSchools.location_info
|
||||
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
|
||||
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
|
||||
}
|
||||
</h2>
|
||||
<div className={styles.resultsHeaderActions}>
|
||||
{isLocationSearch && initialSchools.schools.length > 0 && (
|
||||
<div className={styles.viewToggle}>
|
||||
<button
|
||||
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
|
||||
@@ -112,33 +197,30 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Section */}
|
||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>Featured Schools</h2>
|
||||
<p className={styles.sectionDescription}>
|
||||
Explore schools from across England
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearch && resultsView === 'list' && (
|
||||
<div className={styles.resultsHeader}>
|
||||
<h2 aria-live="polite" aria-atomic="true">
|
||||
{initialSchools.total.toLocaleString()} school
|
||||
{initialSchools.total !== 1 ? 's' : ''} found
|
||||
</h2>
|
||||
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
|
||||
{resultsView === 'list' && (
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={e => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (e.target.value === 'default') {
|
||||
params.delete('sort');
|
||||
} else {
|
||||
params.set('sort', e.target.value);
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}}
|
||||
className={styles.sortSelect}
|
||||
>
|
||||
<option value="default">Sort: Relevance</option>
|
||||
<option value="rwm_desc">Highest R, W & M %</option>
|
||||
<option value="rwm_asc">Lowest R, W & M %</option>
|
||||
{(!isSecondaryView || isMixedView) && <option value="rwm_desc">Highest R, W & M %</option>}
|
||||
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest R, W & M %</option>}
|
||||
{(isSecondaryView || isMixedView) && <option value="att8_desc">Highest Attainment 8</option>}
|
||||
{(isSecondaryView || isMixedView) && <option value="att8_asc">Lowest Attainment 8</option>}
|
||||
{isLocationSearch && <option value="distance">Nearest first</option>}
|
||||
<option value="name_asc">Name A–Z</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,7 +229,6 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
|
||||
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
|
||||
{searchParams.get('school_type') && <span className={styles.filterChip}>{searchParams.get('school_type')}</span>}
|
||||
{searchParams.get('postcode') && <span className={styles.filterChip}>Near {searchParams.get('postcode')} ({parseFloat(searchParams.get('radius') || '1')} mi)</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -167,13 +248,14 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
<div className={styles.mapViewContainer}>
|
||||
<div className={styles.mapContainer}>
|
||||
<SchoolMap
|
||||
schools={initialSchools.schools}
|
||||
schools={isLoadingMap ? initialSchools.schools : mapSchools}
|
||||
center={initialSchools.location_info?.coordinates}
|
||||
referencePoint={initialSchools.location_info?.coordinates}
|
||||
onMarkerClick={setSelectedMapSchool}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.compactList}>
|
||||
{initialSchools.schools.map((school) => (
|
||||
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
|
||||
<div
|
||||
key={school.urn}
|
||||
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
||||
@@ -206,6 +288,17 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
<>
|
||||
<div className={styles.schoolList}>
|
||||
{sortedSchools.map((school) => (
|
||||
school.attainment_8_score != null ? (
|
||||
<SecondarySchoolRow
|
||||
key={school.urn}
|
||||
school={school}
|
||||
isLocationSearch={isLocationSearch}
|
||||
onAddToCompare={addSchool}
|
||||
onRemoveFromCompare={removeSchool}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
||||
/>
|
||||
) : (
|
||||
<SchoolRow
|
||||
key={school.urn}
|
||||
school={school}
|
||||
@@ -214,15 +307,25 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
onRemoveFromCompare={removeSchool}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
{initialSchools.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={initialSchools.page}
|
||||
totalPages={initialSchools.total_pages}
|
||||
total={initialSchools.total}
|
||||
/>
|
||||
{(hasMore || allSchools.length < initialSchools.total) && (
|
||||
<div className={styles.loadMoreSection}>
|
||||
<p className={styles.loadMoreCount}>
|
||||
Showing {allSchools.length.toLocaleString()} of {initialSchools.total.toLocaleString()} schools
|
||||
</p>
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className={`btn btn-secondary ${styles.loadMoreButton}`}
|
||||
>
|
||||
{isLoadingMore ? 'Loading...' : 'Load more schools'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -243,7 +346,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
||||
<div className={styles.compactItem}>
|
||||
<div className={styles.compactItemContent}>
|
||||
<div className={styles.compactItemHeader}>
|
||||
<a href={`/school/${school.urn}`} className={styles.compactItemName}>
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.compactItemName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.distance !== undefined && school.distance !== null && (
|
||||
@@ -258,7 +361,14 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
||||
</div>
|
||||
<div className={styles.compactItemStats}>
|
||||
<span className={styles.compactStat}>
|
||||
<strong>{school.rwm_expected_pct !== null ? `${school.rwm_expected_pct}%` : '-'}</strong> RWM
|
||||
<strong>
|
||||
{school.attainment_8_score != null
|
||||
? school.attainment_8_score.toFixed(1)
|
||||
: school.rwm_expected_pct !== null
|
||||
? `${school.rwm_expected_pct}%`
|
||||
: '-'}
|
||||
</strong>{' '}
|
||||
{school.attainment_8_score != null ? 'Att 8' : 'RWM'}
|
||||
</span>
|
||||
<span className={styles.compactStat}>
|
||||
<strong>{school.total_pupils || '-'}</strong> pupils
|
||||
@@ -272,7 +382,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
||||
>
|
||||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||||
</button>
|
||||
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { School } from '@/lib/types';
|
||||
import { schoolUrl } from '@/lib/utils';
|
||||
|
||||
// Fix for default marker icons in Next.js
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
@@ -22,12 +23,14 @@ interface LeafletMapInnerProps {
|
||||
schools: School[];
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
referencePoint?: [number, number];
|
||||
onMarkerClick?: (school: School) => void;
|
||||
}
|
||||
|
||||
export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }: LeafletMapInnerProps) {
|
||||
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick }: LeafletMapInnerProps) {
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const refMarkerRef = useRef<L.Marker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
@@ -42,13 +45,36 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
|
||||
}).addTo(mapRef.current);
|
||||
}
|
||||
|
||||
// Clear existing markers
|
||||
// Clear existing school markers (not the reference pin)
|
||||
mapRef.current.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker) {
|
||||
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
|
||||
mapRef.current!.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
|
||||
// Add reference pin (search location)
|
||||
if (refMarkerRef.current) {
|
||||
refMarkerRef.current.remove();
|
||||
refMarkerRef.current = null;
|
||||
}
|
||||
if (referencePoint && mapRef.current) {
|
||||
const refIcon = L.divIcon({
|
||||
html: `<div style="
|
||||
width: 20px; height: 20px;
|
||||
background: #e07256;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
"></div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
className: '',
|
||||
});
|
||||
refMarkerRef.current = L.marker(referencePoint, { icon: refIcon, zIndexOffset: 1000 })
|
||||
.addTo(mapRef.current)
|
||||
.bindPopup('<strong>Search location</strong>');
|
||||
}
|
||||
|
||||
// Add markers for schools
|
||||
schools.forEach((school) => {
|
||||
if (school.latitude && school.longitude && mapRef.current) {
|
||||
@@ -60,7 +86,7 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
|
||||
<strong style="font-size: 14px; display: block; margin-bottom: 8px;">${school.school_name}</strong>
|
||||
${school.local_authority ? `<div style="font-size: 12px; color: #666; margin-bottom: 4px;">${school.local_authority}</div>` : ''}
|
||||
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">${school.school_type}</div>` : ''}
|
||||
<a href="/school/${school.urn}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
|
||||
<a href="${schoolUrl(school.urn, school.school_name)}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -88,7 +114,7 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
|
||||
return () => {
|
||||
// Don't destroy map on every update, just clean markers
|
||||
};
|
||||
}, [schools, center, zoom, onMarkerClick]);
|
||||
}, [schools, center, zoom, referencePoint, onMarkerClick]);
|
||||
|
||||
// Cleanup map on unmount
|
||||
useEffect(() => {
|
||||
|
||||
83
nextjs-app/components/MetricTooltip.module.css
Normal file
83
nextjs-app/components/MetricTooltip.module.css
Normal file
@@ -0,0 +1,83 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
cursor: help;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.wrapper:hover .icon {
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
width: 220px;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
}
|
||||
|
||||
/* Keep tooltip visible when hovering over it */
|
||||
.wrapper:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Small arrow pointing down */
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--border-color, #e8ddd4);
|
||||
}
|
||||
|
||||
.tooltipLabel {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
}
|
||||
|
||||
.tooltipPlain {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5a4a44);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tooltipDetail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Flip tooltip below when near top of screen */
|
||||
@media (max-width: 480px) {
|
||||
.tooltip {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
31
nextjs-app/components/MetricTooltip.tsx
Normal file
31
nextjs-app/components/MetricTooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||
import styles from './MetricTooltip.module.css';
|
||||
|
||||
interface MetricTooltipProps {
|
||||
metricKey?: string;
|
||||
label?: string;
|
||||
plain?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
|
||||
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
|
||||
const tooltipLabel = label ?? explanation?.label;
|
||||
const tooltipPlain = plain ?? explanation?.plain;
|
||||
const tooltipDetail = detail ?? explanation?.detail;
|
||||
|
||||
if (!tooltipPlain) return null;
|
||||
|
||||
return (
|
||||
<span className={styles.wrapper}>
|
||||
<span className={styles.icon} aria-label={tooltipLabel ?? 'More information'} role="img">ⓘ</span>
|
||||
<span className={styles.tooltip} role="tooltip">
|
||||
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
|
||||
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
|
||||
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ChartOptions,
|
||||
} from 'chart.js';
|
||||
import type { SchoolResult } from '@/lib/types';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import styles from './PerformanceChart.module.css';
|
||||
|
||||
// Register Chart.js components
|
||||
@@ -34,24 +35,50 @@ ChartJS.register(
|
||||
interface PerformanceChartProps {
|
||||
data: SchoolResult[];
|
||||
schoolName: string;
|
||||
isSecondary?: boolean;
|
||||
}
|
||||
|
||||
export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
|
||||
export function PerformanceChart({ data, schoolName, isSecondary = false }: PerformanceChartProps) {
|
||||
// Sort data by year
|
||||
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
||||
const years = sortedData.map(d => d.year.toString());
|
||||
const years = sortedData.map(d => formatAcademicYear(d.year));
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [
|
||||
// Prepare datasets — phase-aware
|
||||
const datasets = isSecondary ? [
|
||||
{
|
||||
label: 'RWM Expected %',
|
||||
label: 'Attainment 8',
|
||||
data: sortedData.map(d => d.attainment_8_score),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'English & Maths Grade 4+',
|
||||
data: sortedData.map(d => d.english_maths_standard_pass_pct),
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.3,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'Progress 8',
|
||||
data: sortedData.map(d => d.progress_8_score),
|
||||
borderColor: 'rgb(245, 158, 11)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1',
|
||||
},
|
||||
] : [
|
||||
{
|
||||
label: 'Reading, Writing & Maths Expected %',
|
||||
data: sortedData.map(d => d.rwm_expected_pct),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'RWM Higher %',
|
||||
label: 'Reading, Writing & Maths Higher %',
|
||||
data: sortedData.map(d => d.rwm_high_pct),
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
@@ -153,14 +180,14 @@ export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
|
||||
position: 'left' as const,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Percentage (%)',
|
||||
text: isSecondary ? 'Score / Percentage (%)' : 'Percentage (%)',
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'bold',
|
||||
},
|
||||
},
|
||||
min: 0,
|
||||
max: 100,
|
||||
max: isSecondary ? undefined : 100,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
@@ -171,7 +198,7 @@ export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
|
||||
position: 'right' as const,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Progress Score',
|
||||
text: isSecondary ? 'Progress 8 Score' : 'Progress Score',
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'bold',
|
||||
|
||||
@@ -22,6 +22,47 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Phase Tabs */
|
||||
.phaseTabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.phaseTab {
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
background: var(--bg-card, white);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* RankingsView Component
|
||||
* Client-side rankings interface with filters
|
||||
* Client-side rankings interface with phase tabs and filters
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -8,10 +8,29 @@
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import styles from './RankingsView.module.css';
|
||||
|
||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||
const SECONDARY_CATEGORIES = ['gcse'];
|
||||
|
||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'Expected Standard', category: 'expected' },
|
||||
{ label: 'Higher Standard', category: 'higher' },
|
||||
{ label: 'Progress Scores', category: 'progress' },
|
||||
{ label: 'Average Scores', category: 'average' },
|
||||
{ label: 'Gender Performance', category: 'gender' },
|
||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||
{ label: 'School Context', category: 'context' },
|
||||
{ label: 'Absence', category: 'absence' },
|
||||
{ label: '3-Year Trends', category: 'trends' },
|
||||
];
|
||||
|
||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'GCSE Performance', category: 'gcse' },
|
||||
];
|
||||
|
||||
interface RankingsViewProps {
|
||||
rankings: RankingEntry[];
|
||||
filters: Filters;
|
||||
@@ -19,6 +38,7 @@ interface RankingsViewProps {
|
||||
selectedMetric: string;
|
||||
selectedArea?: string;
|
||||
selectedYear?: number;
|
||||
selectedPhase?: string;
|
||||
}
|
||||
|
||||
export function RankingsView({
|
||||
@@ -28,12 +48,17 @@ export function RankingsView({
|
||||
selectedMetric,
|
||||
selectedArea,
|
||||
selectedYear,
|
||||
selectedPhase = 'primary',
|
||||
}: RankingsViewProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { addSchool, isSelected } = useComparison();
|
||||
|
||||
const isPrimary = selectedPhase === 'primary';
|
||||
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||
|
||||
const updateFilters = (updates: Record<string, string | undefined>) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
@@ -48,6 +73,11 @@ export function RankingsView({
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handlePhaseChange = (phase: string) => {
|
||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
updateFilters({ phase, metric: defaultMetric });
|
||||
};
|
||||
|
||||
const handleMetricChange = (metric: string) => {
|
||||
updateFilters({ metric });
|
||||
};
|
||||
@@ -63,7 +93,6 @@ export function RankingsView({
|
||||
const handleAddToCompare = (ranking: RankingEntry) => {
|
||||
addSchool({
|
||||
...ranking,
|
||||
// Ensure required School fields are present
|
||||
address: null,
|
||||
postcode: null,
|
||||
latitude: null,
|
||||
@@ -77,6 +106,9 @@ export function RankingsView({
|
||||
const isProgressScore = selectedMetric.includes('progress');
|
||||
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate');
|
||||
|
||||
// Filter metrics to only show relevant categories
|
||||
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Header */}
|
||||
@@ -84,10 +116,26 @@ export function RankingsView({
|
||||
<h1>School Rankings</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Top-performing schools by {metricLabel.toLowerCase()}
|
||||
{!selectedArea && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
||||
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Phase Tabs */}
|
||||
<div className={styles.phaseTabs}>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('primary')}
|
||||
>
|
||||
Primary (KS2)
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('secondary')}
|
||||
>
|
||||
Secondary (GCSE)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentMetricDef?.description && (
|
||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||
)}
|
||||
@@ -107,46 +155,17 @@ export function RankingsView({
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
>
|
||||
<optgroup label="Expected Standard">
|
||||
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
{metrics.filter(m => m.category === 'progress').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
{metrics.filter(m => m.category === 'average').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
{metrics.filter(m => m.category === 'gender').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="School Context">
|
||||
{metrics.filter(m => m.category === 'context').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
{metrics.filter(m => m.category === '3yr').map((metric) => (
|
||||
{optgroups.map(({ label, category }) => {
|
||||
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||
if (groupMetrics.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={category} label={label}>
|
||||
{groupMetrics.map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -180,11 +199,11 @@ export function RankingsView({
|
||||
className={styles.filterSelect}
|
||||
>
|
||||
<option value="">
|
||||
{filters.years.length > 0 ? `${Math.max(...filters.years)} (Latest)` : 'Latest'}
|
||||
{filters.years.length > 0 ? `${formatAcademicYear(Math.max(...filters.years))} (Latest)` : 'Latest'}
|
||||
</option>
|
||||
{filters.years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
{formatAcademicYear(year)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -199,7 +218,7 @@ export function RankingsView({
|
||||
message="Try selecting a different metric, area, or year."
|
||||
action={{
|
||||
label: 'Clear filters',
|
||||
onClick: () => router.push(pathname),
|
||||
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -248,7 +267,7 @@ export function RankingsView({
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.schoolCell}>
|
||||
<a href={`/school/${ranking.urn}`} className={styles.schoolLink}>
|
||||
<a href={schoolUrl(ranking.urn, ranking.school_name)} className={styles.schoolLink}>
|
||||
{ranking.school_name}
|
||||
</a>
|
||||
</td>
|
||||
@@ -258,7 +277,7 @@ export function RankingsView({
|
||||
<strong>{displayValue}</strong>
|
||||
</td>
|
||||
<td className={styles.actionCell}>
|
||||
<a href={`/school/${ranking.urn}`} className="btn btn-tertiary btn-sm">View</a>
|
||||
<a href={schoolUrl(ranking.urn, ranking.school_name)} className="btn btn-tertiary btn-sm">View</a>
|
||||
<button
|
||||
onClick={() => handleAddToCompare(ranking)}
|
||||
disabled={alreadyInComparison}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, calculateTrend, getTrendColor } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, calculateTrend, getTrendColor, schoolUrl } from '@/lib/utils';
|
||||
import styles from './SchoolCard.module.css';
|
||||
|
||||
interface SchoolCardProps {
|
||||
@@ -25,7 +25,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
|
||||
<div className={`${styles.card} ${isInCompare ? styles.cardInCompare : ''}`}>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>
|
||||
<Link href={`/school/${school.urn}`}>
|
||||
<Link href={schoolUrl(school.urn, school.school_name)}>
|
||||
{school.school_name}
|
||||
</Link>
|
||||
</h3>
|
||||
@@ -48,12 +48,35 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(school.rwm_expected_pct !== null || school.reading_progress !== null) && (
|
||||
{(school.rwm_expected_pct != null || school.attainment_8_score != null || school.reading_progress !== null) && (
|
||||
<div className={styles.metrics}>
|
||||
{/* KS4 card metrics for secondary schools */}
|
||||
{school.attainment_8_score != null && (
|
||||
<div className={styles.metric}>
|
||||
<span className={styles.metricLabel}>
|
||||
Attainment 8
|
||||
<span className={styles.metricHint}>avg grade across best 8 GCSEs</span>
|
||||
</span>
|
||||
<div className={styles.metricValue}>
|
||||
<strong>{school.attainment_8_score.toFixed(1)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{school.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.metric}>
|
||||
<span className={styles.metricLabel}>
|
||||
English & Maths Grade 4+
|
||||
<span className={styles.metricHint}>% standard pass in both</span>
|
||||
</span>
|
||||
<div className={styles.metricValue}>
|
||||
<strong>{formatPercentage(school.english_maths_standard_pass_pct)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{school.rwm_expected_pct !== null && (
|
||||
<div className={styles.metric}>
|
||||
<span className={styles.metricLabel}>
|
||||
RWM Expected
|
||||
Reading, Writing & Maths
|
||||
<span className={styles.metricHint}>% meeting expected standard</span>
|
||||
</span>
|
||||
<div className={styles.metricValue}>
|
||||
@@ -123,7 +146,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link href={`/school/${school.urn}`} className="btn btn-primary">
|
||||
<Link href={schoolUrl(school.urn, school.school_name)} className="btn btn-primary">
|
||||
View Details
|
||||
</Link>
|
||||
{onAddToCompare && (
|
||||
|
||||
@@ -5,17 +5,19 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { PerformanceChart } from './PerformanceChart';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { MetricTooltip } from './MetricTooltip';
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData,
|
||||
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||
SchoolAdmissions, SenDetail, Phonics,
|
||||
SchoolDeprivation, SchoolFinance,
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { formatPercentage, formatProgress } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||
import styles from './SchoolDetailView.module.css';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
@@ -37,19 +39,6 @@ const RC_CATEGORIES = [
|
||||
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
|
||||
];
|
||||
|
||||
// 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 progressClass(val: number | null | undefined): string {
|
||||
if (val == null) return '';
|
||||
@@ -82,6 +71,23 @@ export function SchoolDetailView({
|
||||
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
|
||||
// Phase detection
|
||||
const phase = schoolInfo.phase ?? '';
|
||||
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
|
||||
const isPrimary = !isSecondary;
|
||||
|
||||
// National averages (fetched dynamically so they stay current)
|
||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||
useEffect(() => {
|
||||
fetch('/api/national-averages')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setNationalAvg(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const primaryAvg = nationalAvg?.primary ?? {};
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
|
||||
const handleComparisonToggle = () => {
|
||||
if (isInComparison) {
|
||||
removeSchool(schoolInfo.urn);
|
||||
@@ -108,13 +114,18 @@ export function SchoolDetailView({
|
||||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||
|
||||
// Determine whether this school has KS2 or KS4 results to show
|
||||
const hasKS2Results = latestResults != null && latestResults.rwm_expected_pct != null;
|
||||
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
||||
const hasAnyResults = hasKS2Results || hasKS4Results;
|
||||
|
||||
// Build section nav items dynamically — only sections with data
|
||||
const navItems: { id: string; label: string }[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
if (parentView && parentView.total_responses != null && parentView.total_responses > 0)
|
||||
navItems.push({ id: 'parents', label: 'Parents' });
|
||||
if (latestResults) navItems.push({ id: 'sats', label: 'SATs' });
|
||||
if (hasPhonics) navItems.push({ id: 'phonics', label: 'Phonics' });
|
||||
if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' });
|
||||
if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
|
||||
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||
@@ -154,7 +165,7 @@ export function SchoolDetailView({
|
||||
)}
|
||||
{schoolInfo.website && (
|
||||
<span className={styles.headerDetail}>
|
||||
<a href={schoolInfo.website} target="_blank" rel="noopener noreferrer">
|
||||
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
|
||||
School website ↗
|
||||
</a>
|
||||
</span>
|
||||
@@ -328,33 +339,48 @@ export function SchoolDetailView({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* SATs Results (merged with Subject Breakdown) */}
|
||||
{latestResults && (
|
||||
<section id="sats" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>SATs Results ({latestResults.year})</h2>
|
||||
{/* Results Section (SATs for primary, GCSEs for secondary) */}
|
||||
{hasAnyResults && latestResults && (
|
||||
<section id="results" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
||||
</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.
|
||||
{isSecondary
|
||||
? 'GCSE results for Year 11 pupils. National averages shown for comparison.'
|
||||
: 'End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.'}
|
||||
</p>
|
||||
|
||||
{/* Headline numbers: RWM combined */}
|
||||
{/* ── Primary / KS2 content ── */}
|
||||
{hasKS2Results && (
|
||||
<>
|
||||
<div className={styles.metricsGrid}>
|
||||
{latestResults.rwm_expected_pct !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Reading, Writing & Maths combined</div>
|
||||
<div className={styles.metricLabel}>
|
||||
Reading, Writing & Maths combined
|
||||
<MetricTooltip metricKey="rwm_expected_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_expected_pct)}</div>
|
||||
<div className={styles.metricHint}>National avg: {NATIONAL_AVG.rwm_expected}%</div>
|
||||
{primaryAvg.rwm_expected_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.rwm_high_pct !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Exceeding expected level (RWM)</div>
|
||||
<div className={styles.metricLabel}>
|
||||
Exceeding expected level (Reading, Writing & Maths)
|
||||
<MetricTooltip metricKey="rwm_high_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_high_pct)}</div>
|
||||
<div className={styles.metricHint}>National avg: {NATIONAL_AVG.rwm_high}%</div>
|
||||
{primaryAvg.rwm_high_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per-subject detail table */}
|
||||
<div className={styles.metricGroupsGrid} style={{ marginTop: '1rem' }}>
|
||||
<div className={styles.metricGroup}>
|
||||
<h3 className={styles.metricGroupTitle}>Reading</h3>
|
||||
@@ -373,7 +399,10 @@ export function SchoolDetailView({
|
||||
)}
|
||||
{latestResults.reading_progress !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Progress score</span>
|
||||
<span className={styles.metricName}>
|
||||
Progress score
|
||||
<MetricTooltip metricKey="reading_progress" />
|
||||
</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(latestResults.reading_progress)}`}>
|
||||
{formatProgress(latestResults.reading_progress)}
|
||||
</span>
|
||||
@@ -381,7 +410,10 @@ export function SchoolDetailView({
|
||||
)}
|
||||
{latestResults.reading_avg_score !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Average score</span>
|
||||
<span className={styles.metricName}>
|
||||
Average score
|
||||
<MetricTooltip metricKey="reading_avg_score" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -405,7 +437,10 @@ export function SchoolDetailView({
|
||||
)}
|
||||
{latestResults.writing_progress !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Progress score</span>
|
||||
<span className={styles.metricName}>
|
||||
Progress score
|
||||
<MetricTooltip metricKey="writing_progress" />
|
||||
</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(latestResults.writing_progress)}`}>
|
||||
{formatProgress(latestResults.writing_progress)}
|
||||
</span>
|
||||
@@ -431,7 +466,10 @@ export function SchoolDetailView({
|
||||
)}
|
||||
{latestResults.maths_progress !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Progress score</span>
|
||||
<span className={styles.metricName}>
|
||||
Progress score
|
||||
<MetricTooltip metricKey="maths_progress" />
|
||||
</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(latestResults.maths_progress)}`}>
|
||||
{formatProgress(latestResults.maths_progress)}
|
||||
</span>
|
||||
@@ -439,7 +477,10 @@ export function SchoolDetailView({
|
||||
)}
|
||||
{latestResults.maths_avg_score !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Average score</span>
|
||||
<span className={styles.metricName}>
|
||||
Average score
|
||||
<MetricTooltip metricKey="maths_avg_score" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -452,13 +493,107 @@ export function SchoolDetailView({
|
||||
Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Secondary / KS4 content ── */}
|
||||
{hasKS4Results && (
|
||||
<>
|
||||
<div className={styles.metricsGrid}>
|
||||
{latestResults.attainment_8_score !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Attainment 8
|
||||
<MetricTooltip metricKey="attainment_8_score" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.progress_8_score !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Progress 8
|
||||
<MetricTooltip metricKey="progress_8_score" />
|
||||
</div>
|
||||
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score)}`}>
|
||||
{formatProgress(latestResults.progress_8_score)}
|
||||
</div>
|
||||
<div className={styles.metricHint}>0 = national average</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_standard_pass_pct !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
English & Maths Grade 4+
|
||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_strong_pass_pct !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
English & Maths Grade 5+
|
||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EBacc */}
|
||||
{(latestResults.ebacc_entry_pct !== null || latestResults.ebacc_standard_pass_pct !== null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
||||
English Baccalaureate (EBacc)
|
||||
<MetricTooltip metricKey="ebacc_entry_pct" />
|
||||
</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.ebacc_entry_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_standard_pass_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
EBacc Grade 4+
|
||||
<MetricTooltip metricKey="ebacc_standard_pass_pct" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_strong_pass_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
EBacc Grade 5+
|
||||
<MetricTooltip metricKey="ebacc_strong_pass_pct" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Year 1 Phonics */}
|
||||
{hasPhonics && phonics && (
|
||||
{/* Year 1 Phonics — primary only */}
|
||||
{hasPhonics && isPrimary && phonics && (
|
||||
<section id="phonics" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Year 1 Phonics ({phonics.year})</h2>
|
||||
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Phonics is a key early reading skill. Children are tested at the end of Year 1.
|
||||
</p>
|
||||
@@ -466,7 +601,7 @@ export function SchoolDetailView({
|
||||
<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 className={styles.metricHint}>Phonics is a key early reading skill tested at end of Year 1</div>
|
||||
</div>
|
||||
{phonics.year2_phonics_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
@@ -487,21 +622,31 @@ export function SchoolDetailView({
|
||||
<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 className={styles.metricHint}>Average number of pupils per class</div>
|
||||
</div>
|
||||
)}
|
||||
{absenceData?.overall_absence_rate != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Days missed (overall absence)</div>
|
||||
<div className={styles.metricLabel}>
|
||||
Days missed (overall absence)
|
||||
<MetricTooltip metricKey="overall_absence_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
|
||||
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.overall_absence}%</div>
|
||||
{primaryAvg.overall_absence_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{absenceData?.persistent_absence_rate != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Regularly missing school</div>
|
||||
<div className={styles.metricLabel}>
|
||||
Regularly missing school
|
||||
<MetricTooltip metricKey="persistent_absence_pct" />
|
||||
</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>
|
||||
{primaryAvg.persistent_absence_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -511,11 +656,18 @@ export function SchoolDetailView({
|
||||
{/* How Hard to Get In */}
|
||||
{admissions && (
|
||||
<section id="admissions" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({admissions.year})</h2>
|
||||
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({formatAcademicYear(admissions.year)})</h2>
|
||||
{admissions.oversubscribed != null && (
|
||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||
{admissions.oversubscribed
|
||||
? '⚠ Oversubscribed'
|
||||
: '✓ Not Oversubscribed'}
|
||||
</div>
|
||||
)}
|
||||
<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.metricLabel}>{isSecondary ? 'Year 7' : 'Year 3'} places per year</div>
|
||||
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -525,20 +677,13 @@ export function SchoolDetailView({
|
||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offers_pct != null && (
|
||||
{admissions.first_preference_offer_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 className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{admissions.oversubscribed != null && (
|
||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||
{admissions.oversubscribed
|
||||
? '⚠ More applications than places last year'
|
||||
: '✓ Places were available last year'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -556,13 +701,19 @@ export function SchoolDetailView({
|
||||
)}
|
||||
{latestResults?.eal_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>English as an additional language</div>
|
||||
<div className={styles.metricLabel}>
|
||||
English as an additional language
|
||||
<MetricTooltip metricKey="eal_pct" />
|
||||
</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.metricLabel}>
|
||||
Pupils receiving SEN support
|
||||
<MetricTooltip metricKey="sen_support_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -610,7 +761,10 @@ export function SchoolDetailView({
|
||||
{/* Local Area Context */}
|
||||
{hasDeprivation && deprivation && (
|
||||
<section id="local-area" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Local Area Context</h2>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
Local Area Context
|
||||
<MetricTooltip metricKey="idaci_decile" />
|
||||
</h2>
|
||||
<div className={styles.deprivationDots}>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
@@ -631,7 +785,7 @@ export function SchoolDetailView({
|
||||
{/* Finances */}
|
||||
{hasFinance && finance && (
|
||||
<section id="finances" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>School Finances ({finance.year})</h2>
|
||||
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Per-pupil spending shows how much the school has to spend on each child's education.
|
||||
</p>
|
||||
@@ -639,7 +793,7 @@ export function SchoolDetailView({
|
||||
<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 className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
|
||||
</div>
|
||||
{finance.teacher_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
@@ -665,6 +819,7 @@ export function SchoolDetailView({
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={isSecondary}
|
||||
/>
|
||||
</div>
|
||||
{yearlyData.length > 1 && (
|
||||
@@ -675,22 +830,44 @@ export function SchoolDetailView({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Reading, Writing & Maths (expected %)</th>
|
||||
{isSecondary ? (
|
||||
<>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
<th>English & Maths Grade 4+</th>
|
||||
<th>English & Maths Grade 5+</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th>Reading, Writing & Maths (expected %)</th>
|
||||
<th>Exceeding expected (%)</th>
|
||||
<th>Reading Progress</th>
|
||||
<th>Writing Progress</th>
|
||||
<th>Maths Progress</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{yearlyData.map((result) => (
|
||||
<tr key={result.year}>
|
||||
<td className={styles.yearCell}>{result.year}</td>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||
{isSecondary ? (
|
||||
<>
|
||||
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||
<td>{result.english_maths_strong_pass_pct !== null ? formatPercentage(result.english_maths_strong_pass_pct) : '-'}</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
|
||||
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
|
||||
<td>{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td>
|
||||
<td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td>
|
||||
<td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -4,6 +4,34 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mapWrapper.fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.fullscreenBtn {
|
||||
position: absolute;
|
||||
top: 0.625rem;
|
||||
right: 0.625rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: white;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.fullscreenBtn:hover {
|
||||
background: #f4f4f4;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.mapLoading {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import type { School } from '@/lib/types';
|
||||
import styles from './SchoolMap.module.css';
|
||||
|
||||
@@ -24,32 +25,69 @@ interface SchoolMapProps {
|
||||
schools: School[];
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
referencePoint?: [number, number];
|
||||
onMarkerClick?: (school: School) => void;
|
||||
}
|
||||
|
||||
export function SchoolMap({ schools, center, zoom = 13, onMarkerClick }: SchoolMapProps) {
|
||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick }: SchoolMapProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Sync state with browser fullscreen events (e.g. Escape key)
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFsChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
wrapperRef.current?.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Calculate center if not provided
|
||||
const mapCenter: [number, number] = center || (() => {
|
||||
if (schools.length === 0) return [51.5074, -0.1278]; // Default to London
|
||||
if (schools.length === 0) return [51.5074, -0.1278];
|
||||
if (schools.length === 1 && schools[0].latitude && schools[0].longitude) {
|
||||
return [schools[0].latitude, schools[0].longitude];
|
||||
}
|
||||
|
||||
// Calculate average position
|
||||
const validSchools = schools.filter(s => s.latitude && s.longitude);
|
||||
if (validSchools.length === 0) return [51.5074, -0.1278];
|
||||
|
||||
const avgLat = validSchools.reduce((sum, s) => sum + (s.latitude || 0), 0) / validSchools.length;
|
||||
const avgLng = validSchools.reduce((sum, s) => sum + (s.longitude || 0), 0) / validSchools.length;
|
||||
return [avgLat, avgLng];
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={styles.mapWrapper}>
|
||||
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''}`}>
|
||||
<button
|
||||
className={styles.fullscreenBtn}
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'View map fullscreen'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
/* Compress icon */
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>
|
||||
</svg>
|
||||
) : (
|
||||
/* Expand icon */
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<LeafletMap
|
||||
schools={schools}
|
||||
center={mapCenter}
|
||||
zoom={zoom}
|
||||
referencePoint={referencePoint}
|
||||
onMarkerClick={onMarkerClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,16 +6,20 @@
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
animation: rowFadeIn 0.3s ease-out both;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
border-left-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
}
|
||||
|
||||
/* Phase border colours */
|
||||
.phasePrimary { border-left-color: var(--phase-primary, #5b8cbf); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
|
||||
.phaseNursery { border-left-color: var(--phase-nursery, #e0a0b0); }
|
||||
|
||||
.rowInCompare {
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
@@ -32,10 +36,10 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* Line 1: name + type */
|
||||
/* Line 1: name + ofsted */
|
||||
.line1 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -59,15 +63,39 @@
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.schoolType {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
/* Phase label pill */
|
||||
.phaseLabel {
|
||||
display: inline-block;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Line 2: stats */
|
||||
.phaseLabelPrimary { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||
.phaseLabelAllThrough { background: var(--phase-all-through-bg); color: var(--phase-all-through-text); }
|
||||
.phaseLabelNursery { background: var(--phase-nursery-bg); color: var(--phase-nursery-text); }
|
||||
|
||||
/* Line 2: context tags */
|
||||
.line2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.line2 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
/* Line 3: stats */
|
||||
.line3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
@@ -107,17 +135,17 @@
|
||||
.trendDown { color: var(--accent-coral, #e07256); }
|
||||
.trendStable { color: var(--text-muted, #8a847a); }
|
||||
|
||||
/* Line 3: location */
|
||||
.line3 {
|
||||
/* Line 4: location */
|
||||
.line4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 0;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.line3 span:not(:last-child)::after {
|
||||
.line4 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
@@ -162,6 +190,10 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ofstedDate {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||
@@ -171,7 +203,7 @@
|
||||
@media (max-width: 640px) {
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem;
|
||||
padding: 0.875rem;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -183,7 +215,7 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.line2 {
|
||||
.line3 {
|
||||
gap: 0 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* SchoolRow Component
|
||||
* Three-line row for school search results
|
||||
* Four-line row for primary school search results
|
||||
*
|
||||
* Line 1: School name · School type
|
||||
* Line 2: R,W&M % · Progress score · Pupil count
|
||||
* Line 3: Local authority · Distance
|
||||
* Line 1: School name · Ofsted badge
|
||||
* Line 2: School type · Age range · Denomination · Gender
|
||||
* Line 3: R,W&M % · Progress score · Pupil count
|
||||
* Line 4: Local authority · Distance
|
||||
*/
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, calculateTrend } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, calculateTrend, getPhaseStyle, schoolUrl } from '@/lib/utils';
|
||||
import { progressBand } from '@/lib/metrics';
|
||||
import styles from './SchoolRow.module.css';
|
||||
|
||||
@@ -35,6 +36,7 @@ export function SchoolRow({
|
||||
onRemoveFromCompare,
|
||||
}: SchoolRowProps) {
|
||||
const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct);
|
||||
const phase = getPhaseStyle(school.phase);
|
||||
|
||||
// Use reading progress as representative; fall back to writing, then maths
|
||||
const progressScore =
|
||||
@@ -48,28 +50,48 @@ export function SchoolRow({
|
||||
}
|
||||
};
|
||||
|
||||
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
|
||||
const showDenomination =
|
||||
school.religious_denomination &&
|
||||
school.religious_denomination !== 'Does not apply';
|
||||
|
||||
return (
|
||||
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
|
||||
{/* Left: three content lines */}
|
||||
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
|
||||
{/* Left: four content lines */}
|
||||
<div className={styles.rowContent}>
|
||||
|
||||
{/* Line 1: School name + type + Ofsted badge */}
|
||||
{/* Line 1: School name + Ofsted badge */}
|
||||
<div className={styles.line1}>
|
||||
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.school_type && (
|
||||
<span className={styles.schoolType}>{school.school_type}</span>
|
||||
)}
|
||||
{school.ofsted_grade && (
|
||||
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
||||
{OFSTED_LABELS[school.ofsted_grade]}
|
||||
{school.ofsted_date && (
|
||||
<span className={styles.ofstedDate}>
|
||||
{' '}({new Date(school.ofsted_date).getFullYear()})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 2: Key stats */}
|
||||
{/* Line 2: Context tags */}
|
||||
<div className={styles.line2}>
|
||||
{phase.label && (
|
||||
<span className={`${styles.phaseLabel} ${styles[`phaseLabel${phase.key}`]}`}>
|
||||
{phase.label}
|
||||
</span>
|
||||
)}
|
||||
{school.school_type && <span>{school.school_type}</span>}
|
||||
{school.age_range && <span>{school.age_range}</span>}
|
||||
{showDenomination && <span>{school.religious_denomination}</span>}
|
||||
{showGender && <span>{school.gender}</span>}
|
||||
</div>
|
||||
|
||||
{/* Line 3: Key stats */}
|
||||
<div className={styles.line3}>
|
||||
{school.rwm_expected_pct != null ? (
|
||||
<span className={styles.stat}>
|
||||
<strong className={styles.statValue}>
|
||||
@@ -123,8 +145,8 @@ export function SchoolRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 3: Location + distance */}
|
||||
<div className={styles.line3}>
|
||||
{/* Line 4: Location + distance */}
|
||||
<div className={styles.line4}>
|
||||
{school.local_authority && (
|
||||
<span>{school.local_authority}</span>
|
||||
)}
|
||||
@@ -133,18 +155,13 @@ export function SchoolRow({
|
||||
{school.distance.toFixed(1)} mi
|
||||
</span>
|
||||
)}
|
||||
{!isLocationSearch &&
|
||||
school.religious_denomination &&
|
||||
school.religious_denomination !== 'Does not apply' && (
|
||||
<span>{school.religious_denomination}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right: actions, vertically centred */}
|
||||
<div className={styles.rowActions}>
|
||||
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
|
||||
View
|
||||
</a>
|
||||
{(onAddToCompare || onRemoveFromCompare) && (
|
||||
|
||||
773
nextjs-app/components/SecondarySchoolDetailView.module.css
Normal file
773
nextjs-app/components/SecondarySchoolDetailView.module.css
Normal file
@@ -0,0 +1,773 @@
|
||||
/* SecondarySchoolDetailView — borrows heavily from SchoolDetailView.module.css */
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────── */
|
||||
.header {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 0;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.badgeSelective {
|
||||
background: rgba(180, 120, 0, 0.1);
|
||||
color: #8a6200;
|
||||
}
|
||||
|
||||
.badgeFaith {
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.address {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin: 0 0 0.75rem;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btnAdd,
|
||||
.btnRemove {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btnAdd {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btnAdd:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btnRemove {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btnRemove:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ── Tab Navigation (sticky) ─────────────────────────── */
|
||||
.tabNav {
|
||||
position: sticky;
|
||||
top: 3.5rem;
|
||||
z-index: 10;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tabNav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabNavInner {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral, #e07256);
|
||||
background: none;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.backBtn:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.tabNavDivider {
|
||||
width: 1px;
|
||||
height: 1rem;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
margin: 0 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabBtn {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tabBtn:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
/* ── Card ────────────────────────────────────────────── */
|
||||
.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);
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
/* ── Section Title ───────────────────────────────────── */
|
||||
.sectionTitle {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.875rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionTitle::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin: -0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.subSectionTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── Progress 8 suspension banner ───────────────────── */
|
||||
.p8Banner {
|
||||
background: rgba(180, 120, 0, 0.1);
|
||||
border: 1px solid rgba(180, 120, 0, 0.3);
|
||||
color: #8a6200;
|
||||
border-radius: 6px;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.825rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Metrics Grid & Cards ────────────────────────────── */
|
||||
.metricsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metricCard {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.metricHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin-top: 0.3rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Progress score colours ──────────────────────────── */
|
||||
.progressPositive {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.progressNegative {
|
||||
color: var(--accent-coral, #e07256);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Status colours ──────────────────────────────────── */
|
||||
.statusGood {
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.statusWarn {
|
||||
background: var(--accent-gold-bg);
|
||||
color: #b8920e;
|
||||
}
|
||||
|
||||
/* ── Metric table (row-based) ────────────────────────── */
|
||||
.metricTable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.metricRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.metricName {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
}
|
||||
|
||||
.metricRow .metricValue {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
/* ── Charts & Map ────────────────────────────────────── */
|
||||
.chartContainer {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── History table ───────────────────────────────────── */
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.historicalSubtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
margin: 1.25rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.dataTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.dataTable thead {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.dataTable th {
|
||||
padding: 0.625rem 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.dataTable td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
}
|
||||
|
||||
.dataTable tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dataTable tbody tr:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.yearCell {
|
||||
font-weight: 600;
|
||||
color: var(--accent-gold, #c9a227);
|
||||
}
|
||||
|
||||
/* ── 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: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofstedGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||
.ofstedGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||
.ofstedGrade4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
|
||||
.rcGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.rcGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||
.rcGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||
.rcGrade4 { background: rgba(249, 115, 22, 0.12); color: #c2410c; }
|
||||
.rcGrade5 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
|
||||
.safeguardingMet {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.safeguardingNotMet {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.ofstedDisclaimer {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
font-style: italic;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.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 ─────────────────────────────────────── */
|
||||
.parentRecommendLine {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.parentRecommendLine strong {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.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 ──────────────────────────────────────── */
|
||||
.admissionsTypeBadge {
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admissionsSelective {
|
||||
background: rgba(180, 120, 0, 0.1);
|
||||
color: #8a6200;
|
||||
border: 1px solid rgba(180, 120, 0, 0.25);
|
||||
}
|
||||
|
||||
.admissionsFaith {
|
||||
background: rgba(45, 125, 125, 0.08);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
border: 1px solid rgba(45, 125, 125, 0.2);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.sixthFormNote {
|
||||
margin-top: 1rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-radius: 6px;
|
||||
font-size: 0.825rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
/* ── Deprivation ─────────────────────────────────────── */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btnAdd,
|
||||
.btnRemove {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 1.25rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.badges {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.375rem;
|
||||
}
|
||||
|
||||
.headerDetails {
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.metricsGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.dataTable {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.dataTable th,
|
||||
.dataTable td {
|
||||
padding: 0.5rem 0.375rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.parentViewLabel {
|
||||
flex-basis: 10rem;
|
||||
}
|
||||
|
||||
.ofstedReportLink {
|
||||
margin-left: 0;
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.admissionsTypeBadge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.parentViewRow {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.parentViewLabel {
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.parentViewBar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.parentViewPct {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.metricsGrid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metricCard {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
709
nextjs-app/components/SecondarySchoolDetailView.tsx
Normal file
709
nextjs-app/components/SecondarySchoolDetailView.tsx
Normal file
@@ -0,0 +1,709 @@
|
||||
/**
|
||||
* SecondarySchoolDetailView Component
|
||||
* Dedicated detail view for secondary schools with scroll-to-section navigation.
|
||||
* All sections render at once; the sticky nav scrolls to each.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { PerformanceChart } from './PerformanceChart';
|
||||
import { MetricTooltip } from './MetricTooltip';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData,
|
||||
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||
SchoolAdmissions, SenDetail, Phonics,
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||
import styles from './SecondarySchoolDetailView.module.css';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
||||
};
|
||||
|
||||
const RC_LABELS: Record<number, string> = {
|
||||
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
|
||||
};
|
||||
|
||||
const RC_CATEGORIES = [
|
||||
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
|
||||
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
|
||||
{ key: 'rc_achievement' as const, label: 'Achievement' },
|
||||
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
|
||||
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
|
||||
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
|
||||
{ key: 'rc_early_years' as const, label: 'Early Years' },
|
||||
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
|
||||
];
|
||||
|
||||
function progressClass(val: number | null | undefined, modStyles: Record<string, string>): string {
|
||||
if (val == null) return '';
|
||||
if (val > 0) return modStyles.progressPositive;
|
||||
if (val < 0) return modStyles.progressNegative;
|
||||
return '';
|
||||
}
|
||||
|
||||
function deprivationDesc(decile: number): string {
|
||||
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).`;
|
||||
}
|
||||
|
||||
interface SecondarySchoolDetailViewProps {
|
||||
schoolInfo: School;
|
||||
yearlyData: SchoolResult[];
|
||||
absenceData: AbsenceData | null;
|
||||
ofsted: OfstedInspection | null;
|
||||
parentView: OfstedParentView | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
senDetail: SenDetail | null;
|
||||
phonics: Phonics | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
}
|
||||
|
||||
export function SecondarySchoolDetailView({
|
||||
schoolInfo, yearlyData,
|
||||
ofsted, parentView, admissions, senDetail, deprivation, finance, absenceData,
|
||||
}: SecondarySchoolDetailViewProps) {
|
||||
const router = useRouter();
|
||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||
const isInComparison = isSelected(schoolInfo.urn);
|
||||
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
|
||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/national-averages')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setNationalAvg(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
|
||||
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
|
||||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||||
const hasParents = parentView != null && parentView.total_responses != null && parentView.total_responses > 0;
|
||||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
|
||||
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
||||
const hasResults = latestResults?.attainment_8_score != null;
|
||||
|
||||
const admissionsTag = (() => {
|
||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||
if (policy.includes('selective')) return 'Selective';
|
||||
const denom = schoolInfo.religious_denomination ?? '';
|
||||
if (denom && denom !== 'Does not apply') return 'Faith priority';
|
||||
return null;
|
||||
})();
|
||||
|
||||
const handleComparisonToggle = () => {
|
||||
if (isInComparison) {
|
||||
removeSchool(schoolInfo.urn);
|
||||
} else {
|
||||
addSchool(schoolInfo);
|
||||
}
|
||||
};
|
||||
|
||||
// Build nav items dynamically based on available data
|
||||
const navItems: { id: string; label: string }[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
if (hasParents) navItems.push({ id: 'parents', label: 'Parents' });
|
||||
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||
if (hasLocation) navItems.push({ id: 'location', label: 'Location' });
|
||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* ── Header ─────────────────────────────────────── */}
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||
<div className={styles.badges}>
|
||||
{schoolInfo.school_type && (
|
||||
<span className={styles.badge}>{schoolInfo.school_type}</span>
|
||||
)}
|
||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||
<span className={styles.badge}>{schoolInfo.gender}'s school</span>
|
||||
)}
|
||||
{schoolInfo.age_range && (
|
||||
<span className={styles.badge}>{schoolInfo.age_range}</span>
|
||||
)}
|
||||
{hasSixthForm && (
|
||||
<span className={styles.badge}>Sixth form</span>
|
||||
)}
|
||||
{admissionsTag && (
|
||||
<span className={`${styles.badge} ${admissionsTag === 'Selective' ? styles.badgeSelective : styles.badgeFaith}`}>
|
||||
{admissionsTag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{schoolInfo.address && (
|
||||
<p className={styles.address}>
|
||||
{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={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${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
|
||||
onClick={handleComparisonToggle}
|
||||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||||
>
|
||||
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Sticky section navigation ─────────────────────── */}
|
||||
<nav className={styles.tabNav} aria-label="Page sections">
|
||||
<div className={styles.tabNavInner}>
|
||||
<button onClick={() => router.back()} className={styles.backBtn}>← Back</button>
|
||||
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
|
||||
{navItems.map(({ id, label }) => (
|
||||
<a key={id} href={`#${id}`} className={styles.tabBtn}>{label}</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── Ofsted ─────────────────────────────────────── */}
|
||||
{ofsted && (
|
||||
<section id="ofsted" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : '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>
|
||||
{ofsted.framework === 'ReportCard' ? (
|
||||
<>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||
</p>
|
||||
<div className={styles.metricsGrid}>
|
||||
{ofsted.rc_safeguarding_met != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Safeguarding</div>
|
||||
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
|
||||
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{RC_CATEGORIES.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).map(({ key, label }) => {
|
||||
const value = ofsted[key] as number | null;
|
||||
return value != null ? (
|
||||
<div key={key} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
|
||||
{RC_LABELS[value]}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : ofsted.overall_effectiveness ? (
|
||||
<>
|
||||
<div className={styles.ofstedHeader}>
|
||||
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
|
||||
{OFSTED_LABELS[ofsted.overall_effectiveness]}
|
||||
</span>
|
||||
{ofsted.previous_overall != null &&
|
||||
ofsted.previous_overall !== ofsted.overall_effectiveness && (
|
||||
<span className={styles.ofstedPrevious}>
|
||||
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
|
||||
</p>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
From September 2024, Ofsted no longer gives a single overall grade.
|
||||
</p>
|
||||
<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 },
|
||||
].filter(({ value }) => value != null).map(({ label, value }) => (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{hasParents && (
|
||||
<p className={styles.parentRecommendLine}>
|
||||
<strong>{Math.round(parentView!.q_recommend_pct!)}%</strong> of parents would recommend this school ({parentView!.total_responses!.toLocaleString()} responses)
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Parent View ────────────────────────────────── */}
|
||||
{hasParents && parentView && (
|
||||
<section id="parents" 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>
|
||||
)}
|
||||
|
||||
{/* ── GCSE Results ───────────────────────────────── */}
|
||||
{hasResults && latestResults && (
|
||||
<section id="gcse" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||||
</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
GCSE results for Year 11 pupils. National averages shown for comparison.
|
||||
</p>
|
||||
|
||||
{p8Suspended && (
|
||||
<div className={styles.p8Banner}>
|
||||
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.metricsGrid}>
|
||||
{latestResults.attainment_8_score != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Attainment 8
|
||||
<MetricTooltip metricKey="attainment_8_score" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.progress_8_score != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Progress 8
|
||||
<MetricTooltip metricKey="progress_8_score" />
|
||||
</div>
|
||||
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
|
||||
{formatProgress(latestResults.progress_8_score)}
|
||||
</div>
|
||||
{(latestResults.progress_8_lower_ci != null || latestResults.progress_8_upper_ci != null) && (
|
||||
<div className={styles.metricHint}>
|
||||
CI: {latestResults.progress_8_lower_ci?.toFixed(2) ?? '?'} to {latestResults.progress_8_upper_ci?.toFixed(2) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
English & Maths Grade 4+
|
||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
English & Maths Grade 5+
|
||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress 8 component breakdown */}
|
||||
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
|
||||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{[
|
||||
{ label: 'English', val: latestResults.progress_8_english },
|
||||
{ label: 'Maths', val: latestResults.progress_8_maths },
|
||||
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
|
||||
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
|
||||
].filter(r => r.val != null).map(({ label, val }) => (
|
||||
<div key={label} className={styles.metricRow}>
|
||||
<span className={styles.metricName}>{label}</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(val, styles)}`}>
|
||||
{formatProgress(val!)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* EBacc */}
|
||||
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
||||
English Baccalaureate (EBacc)
|
||||
<MetricTooltip metricKey="ebacc_entry_pct" />
|
||||
</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.ebacc_entry_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_standard_pass_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc Grade 4+</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_strong_pass_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc Grade 5+</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_avg_score != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc average point score</span>
|
||||
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Performance chart */}
|
||||
{yearlyData.length > 0 && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Admissions ─────────────────────────────────── */}
|
||||
{admissions && (
|
||||
<section id="admissions" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Admissions</h2>
|
||||
|
||||
{admissionsTag && (
|
||||
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
|
||||
<strong>{admissionsTag}</strong>{' '}
|
||||
{admissionsTag === 'Selective'
|
||||
? '— Entry to this school is by selective examination (e.g. 11+).'
|
||||
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.metricsGrid}>
|
||||
{admissions.published_admission_number != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Year 7 places per year (PAN)</div>
|
||||
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total applications</div>
|
||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>1st preference applications</div>
|
||||
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Families who got their first choice</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{admissions.oversubscribed != null && (
|
||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||
{admissions.oversubscribed
|
||||
? '⚠ Applications exceeded places last year'
|
||||
: '✓ Places were available last year'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
||||
</p>
|
||||
|
||||
{hasSixthForm && (
|
||||
<div className={styles.sixthFormNote}>
|
||||
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Wellbeing ──────────────────────────────────── */}
|
||||
{hasWellbeing && (
|
||||
<section id="wellbeing" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Wellbeing & Context</h2>
|
||||
|
||||
{/* SEN */}
|
||||
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
|
||||
<div className={styles.metricsGrid}>
|
||||
{latestResults?.sen_support_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Pupils receiving SEN support
|
||||
<MetricTooltip metricKey="sen_support_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
||||
<div className={styles.metricHint}>SEN support without an EHCP</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.sen_ehcp_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Pupils with an EHCP
|
||||
<MetricTooltip metricKey="sen_ehcp_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
||||
<div className={styles.metricHint}>Education, Health and Care Plan</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.total_pupils != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total pupils</div>
|
||||
<div className={styles.metricValue}>{latestResults.total_pupils.toLocaleString()}</div>
|
||||
{schoolInfo.capacity != null && (
|
||||
<div className={styles.metricHint}>Capacity: {schoolInfo.capacity}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deprivation */}
|
||||
{hasDeprivation && deprivation && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
|
||||
Local Area Context
|
||||
<MetricTooltip metricKey="idaci_decile" />
|
||||
</h3>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── Location ───────────────────────────────────── */}
|
||||
{hasLocation && (
|
||||
<section id="location" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Location</h2>
|
||||
<div className={styles.mapContainer}>
|
||||
<SchoolMap
|
||||
schools={[schoolInfo]}
|
||||
center={[schoolInfo.latitude!, schoolInfo.longitude!]}
|
||||
zoom={15}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Finances ───────────────────────────────────── */}
|
||||
{hasFinance && finance && (
|
||||
<section id="finances" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Per-pupil spending shows how much the school has to spend on each child'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}>How much the school has to spend on each pupil annually</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>
|
||||
)}
|
||||
{finance.premises_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on premises</div>
|
||||
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── History table ──────────────────────────────── */}
|
||||
{yearlyData.length > 1 && (
|
||||
<section id="history" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Historical Results</h2>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.dataTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
<th>Eng & Maths 4+</th>
|
||||
<th>EBacc entry %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{yearlyData.map((result) => (
|
||||
<tr key={result.year}>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||
<td>{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||
<td>{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||
<td>{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user