Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf2e8f262 | |||
| 8ce34b3ecc | |||
| 9c50c49e1f | |||
| 177571f411 | |||
| 51310160a8 | |||
| 2c13b21360 | |||
| ad2fe5bbef | |||
| 58f8eae997 | |||
| 44fdcfa18b | |||
| b1e025d468 | |||
| 9ebb421307 | |||
| 8a6758b591 | |||
| 6d02d366ce | |||
| fe31be34a0 | |||
| 109fa14ccb | |||
| 06bf53ac26 | |||
| dc66e22d4d | |||
| a3cfffa4d0 | |||
| 23f881b797 | |||
| e625addc3b | |||
| 536a166b35 | |||
| e72345bad5 | |||
| dfa8058efc | |||
| 41cefeedf6 | |||
| 1e5c66d6ab | |||
| 3458195865 | |||
| 24b3688df0 | |||
| 2d6e39eebc | |||
| c749d72a6a | |||
| f053b35c6f | |||
| ca5f6a962c | |||
| ed244ef743 | |||
| ce46db7dbe | |||
| a562f408d2 | |||
| 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
|
REGISTRY: privaterepo.sitaru.org
|
||||||
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
|
||||||
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
|
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
|
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -112,94 +110,6 @@ jobs:
|
|||||||
# cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:buildcache
|
# 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
|
# 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:
|
build-pipeline:
|
||||||
name: Build Pipeline (Meltano + dbt + Airflow)
|
name: Build Pipeline (Meltano + dbt + Airflow)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -249,7 +159,7 @@ jobs:
|
|||||||
trigger-deployment:
|
trigger-deployment:
|
||||||
name: Trigger Portainer Update
|
name: Trigger Portainer Update
|
||||||
runs-on: ubuntu-latest
|
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'
|
if: gitea.event_name != 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger Portainer stack update
|
- name: Trigger Portainer stack update
|
||||||
|
|||||||
@@ -22,13 +22,10 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY backend/ ./backend/
|
COPY backend/ ./backend/
|
||||||
COPY frontend/ ./frontend/
|
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
COPY data/ ./data/
|
|
||||||
|
|
||||||
# Expose the application port
|
# Expose the application port
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Run the application (using module import)
|
# Run the application (using module import)
|
||||||
CMD ["python", "-m", "uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "80"]
|
CMD ["python", "-m", "uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "80"]
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ The application tracks these Key Stage 2 performance indicators:
|
|||||||
| **Reading Expected %** | Percentage meeting expected standard in reading |
|
| **Reading Expected %** | Percentage meeting expected standard in reading |
|
||||||
| **Writing Expected %** | Percentage meeting expected standard in writing |
|
| **Writing Expected %** | Percentage meeting expected standard in writing |
|
||||||
| **Maths Expected %** | Percentage meeting expected standard in maths |
|
| **Maths Expected %** | Percentage meeting expected standard in maths |
|
||||||
| **RWM Combined %** | Percentage meeting expected standard in all three subjects |
|
| **Reading, Writing & Maths Combined %** | Percentage meeting expected standard in all three subjects |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ If using your own CSV data, ensure it includes these columns (or similar):
|
|||||||
| READPROG | Float | Reading progress score |
|
| READPROG | Float | Reading progress score |
|
||||||
| WRITPROG | Float | Writing progress score |
|
| WRITPROG | Float | Writing progress score |
|
||||||
| MATPROG | Float | Maths progress score |
|
| MATPROG | Float | Maths progress score |
|
||||||
| PTRWM_EXP | Float | % meeting expected standard in RWM |
|
| PTRWM_EXP | Float | % meeting expected standard in reading, writing & maths |
|
||||||
| PTREAD_EXP | Float | % meeting expected standard in reading |
|
| PTREAD_EXP | Float | % meeting expected standard in reading |
|
||||||
| PTWRIT_EXP | Float | % meeting expected standard in writing |
|
| PTWRIT_EXP | Float | % meeting expected standard in writing |
|
||||||
| PTMAT_EXP | Float | % meeting expected standard in maths |
|
| PTMAT_EXP | Float | % meeting expected standard in maths |
|
||||||
|
|||||||
+333
-88
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
SchoolCompare.co.uk API
|
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.
|
Uses real data from UK Government Compare School Performance downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -26,13 +26,91 @@ from .data_loader import (
|
|||||||
load_school_data,
|
load_school_data,
|
||||||
geocode_single_postcode,
|
geocode_single_postcode,
|
||||||
get_supplementary_data,
|
get_supplementary_data,
|
||||||
|
search_schools_typesense,
|
||||||
)
|
)
|
||||||
from .data_loader import get_data_info as get_db_info
|
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 .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||||
from .utils import clean_for_json
|
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
|
# SECURITY MIDDLEWARE & HELPERS
|
||||||
@@ -138,26 +216,28 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan - startup and shutdown events."""
|
"""Application lifespan - startup and shutdown events."""
|
||||||
# Startup: check schema version and migrate if needed
|
global _sitemap_xml
|
||||||
print("Starting up: Checking database schema...")
|
print("Loading school data from marts...")
|
||||||
check_and_migrate_if_needed()
|
|
||||||
|
|
||||||
print("Loading school data from database...")
|
|
||||||
df = load_school_data()
|
df = load_school_data()
|
||||||
if df.empty:
|
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:
|
else:
|
||||||
print(f"Data loaded successfully: {len(df)} records.")
|
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...")
|
print("Shutting down...")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SchoolCompare API",
|
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",
|
version="2.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
# Disable docs in production for security
|
# Disable docs in production for security
|
||||||
@@ -219,21 +299,26 @@ async def get_schools(
|
|||||||
None, description="Filter by local authority", max_length=100
|
None, description="Filter by local authority", max_length=100
|
||||||
),
|
),
|
||||||
school_type: Optional[str] = Query(None, description="Filter by school type", 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),
|
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: 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.
|
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
|
# Sanitize inputs
|
||||||
search = sanitize_search_input(search)
|
search = sanitize_search_input(search)
|
||||||
local_authority = sanitize_search_input(local_authority)
|
local_authority = sanitize_search_input(local_authority)
|
||||||
school_type = sanitize_search_input(school_type)
|
school_type = sanitize_search_input(school_type)
|
||||||
|
phase = sanitize_search_input(phase)
|
||||||
postcode = validate_postcode(postcode)
|
postcode = validate_postcode(postcode)
|
||||||
|
|
||||||
df = load_school_data()
|
df = load_school_data()
|
||||||
@@ -245,6 +330,11 @@ async def get_schools(
|
|||||||
if page_size is None:
|
if page_size is None:
|
||||||
page_size = settings.default_page_size
|
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)
|
# Get unique schools (latest year data for each)
|
||||||
latest_year = df.groupby("urn")["year"].max().reset_index()
|
latest_year = df.groupby("urn")["year"].max().reset_index()
|
||||||
df_latest = df.merge(latest_year, on=["urn", "year"])
|
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(
|
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
||||||
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
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")
|
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
|
# Include key result metrics for display on cards
|
||||||
location_cols = ["latitude", "longitude"]
|
location_cols = ["latitude", "longitude"]
|
||||||
result_cols = [
|
result_cols = [
|
||||||
|
"phase",
|
||||||
"year",
|
"year",
|
||||||
"rwm_expected_pct",
|
"rwm_expected_pct",
|
||||||
"rwm_high_pct",
|
"rwm_high_pct",
|
||||||
"prev_rwm_expected_pct",
|
"prev_rwm_expected_pct",
|
||||||
|
"prev_attainment_8_score",
|
||||||
"reading_expected_pct",
|
"reading_expected_pct",
|
||||||
"writing_expected_pct",
|
"writing_expected_pct",
|
||||||
"maths_expected_pct",
|
"maths_expected_pct",
|
||||||
"total_pupils",
|
"total_pupils",
|
||||||
|
"attainment_8_score",
|
||||||
|
"english_maths_standard_pass_pct",
|
||||||
]
|
]
|
||||||
available_cols = [
|
available_cols = [
|
||||||
c
|
c
|
||||||
for c in SCHOOL_COLUMNS + location_cols + result_cols
|
for c in SCHOOL_COLUMNS + location_cols + result_cols
|
||||||
if c in df_latest.columns
|
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)
|
# Location-based search (uses pre-geocoded data from database)
|
||||||
search_coords = None
|
search_coords = None
|
||||||
@@ -321,15 +444,19 @@ async def get_schools(
|
|||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if search:
|
if search:
|
||||||
search_lower = search.lower()
|
ts_urns = search_schools_typesense(search)
|
||||||
mask = (
|
if ts_urns:
|
||||||
schools_df["school_name"].str.lower().str.contains(search_lower, na=False)
|
urn_order = {urn: i for i, urn in enumerate(ts_urns)}
|
||||||
)
|
schools_df = schools_df[schools_df["urn"].isin(set(ts_urns))].copy()
|
||||||
if "address" in schools_df.columns:
|
schools_df["_ts_rank"] = schools_df["urn"].map(urn_order)
|
||||||
mask = mask | schools_df["address"].str.lower().str.contains(
|
schools_df = schools_df.sort_values("_ts_rank").drop(columns=["_ts_rank"])
|
||||||
search_lower, na=False
|
else:
|
||||||
)
|
# Fallback: Typesense unavailable, use substring match
|
||||||
schools_df = schools_df[mask]
|
search_lower = search.lower()
|
||||||
|
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)
|
||||||
|
schools_df = schools_df[mask]
|
||||||
|
|
||||||
if local_authority:
|
if local_authority:
|
||||||
schools_df = schools_df[
|
schools_df = schools_df[
|
||||||
@@ -341,6 +468,18 @@ async def get_schools(
|
|||||||
schools_df["school_type"].str.lower() == school_type.lower()
|
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
|
# Pagination
|
||||||
total = len(schools_df)
|
total = len(schools_df)
|
||||||
start_idx = (page - 1) * page_size
|
start_idx = (page - 1) * page_size
|
||||||
@@ -353,6 +492,7 @@ async def get_schools(
|
|||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
||||||
|
"result_filters": result_filters,
|
||||||
"location_info": {
|
"location_info": {
|
||||||
"postcode": postcode,
|
"postcode": postcode,
|
||||||
"radius": radius * 1.60934, # Convert miles to km for frontend display
|
"radius": radius * 1.60934, # Convert miles to km for frontend display
|
||||||
@@ -366,7 +506,7 @@ async def get_schools(
|
|||||||
@app.get("/api/schools/{urn}")
|
@app.get("/api/schools/{urn}")
|
||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_school_details(request: Request, urn: int):
|
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)
|
# Validate URN range (UK school URNs are 6 digits)
|
||||||
if not (100000 <= urn <= 999999):
|
if not (100000 <= urn <= 999999):
|
||||||
raise HTTPException(status_code=400, detail="Invalid URN format")
|
raise HTTPException(status_code=400, detail="Invalid URN format")
|
||||||
@@ -408,11 +548,12 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"age_range": latest.get("age_range", ""),
|
"age_range": latest.get("age_range", ""),
|
||||||
"latitude": latest.get("latitude"),
|
"latitude": latest.get("latitude"),
|
||||||
"longitude": latest.get("longitude"),
|
"longitude": latest.get("longitude"),
|
||||||
"phase": "Primary",
|
"phase": latest.get("phase"),
|
||||||
# GIAS fields
|
# GIAS fields
|
||||||
"website": latest.get("website"),
|
"website": latest.get("website"),
|
||||||
"headteacher_name": latest.get("headteacher_name"),
|
"headteacher_name": latest.get("headteacher_name"),
|
||||||
"capacity": latest.get("capacity"),
|
"capacity": latest.get("capacity"),
|
||||||
|
"total_pupils": latest.get("gias_total_pupils"),
|
||||||
"trust_name": latest.get("trust_name"),
|
"trust_name": latest.get("trust_name"),
|
||||||
"gender": latest.get("gender"),
|
"gender": latest.get("gender"),
|
||||||
},
|
},
|
||||||
@@ -435,7 +576,7 @@ async def compare_schools(
|
|||||||
request: Request,
|
request: Request,
|
||||||
urns: str = Query(..., description="Comma-separated URNs", max_length=100)
|
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()
|
df = load_school_data()
|
||||||
|
|
||||||
if df.empty:
|
if df.empty:
|
||||||
@@ -468,7 +609,11 @@ async def compare_schools(
|
|||||||
"urn": urn,
|
"urn": urn,
|
||||||
"school_name": latest.get("school_name", ""),
|
"school_name": latest.get("school_name", ""),
|
||||||
"local_authority": latest.get("local_authority", ""),
|
"local_authority": latest.get("local_authority", ""),
|
||||||
|
"school_type": latest.get("school_type", ""),
|
||||||
"address": latest.get("address", ""),
|
"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),
|
"yearly_data": clean_for_json(school_data),
|
||||||
}
|
}
|
||||||
@@ -489,10 +634,132 @@ async def get_filter_options(request: Request):
|
|||||||
"years": [],
|
"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 {
|
return {
|
||||||
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
|
"local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [],
|
||||||
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
|
"school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [],
|
||||||
"years": sorted(df["year"].dropna().unique().tolist()),
|
"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": {}}
|
||||||
|
|
||||||
|
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", "eal_pct",
|
||||||
|
]
|
||||||
|
ks4_metrics = [
|
||||||
|
"attainment_8_score", "progress_8_score",
|
||||||
|
"english_maths_standard_pass_pct", "english_maths_strong_pass_pct",
|
||||||
|
"ebacc_entry_pct", "ebacc_standard_pass_pct", "ebacc_strong_pass_pct",
|
||||||
|
"ebacc_avg_score", "gcse_grade_91_pct",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _means(sub_df, metric_list):
|
||||||
|
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
|
||||||
|
|
||||||
|
latest_year = int(df["year"].max())
|
||||||
|
df_latest = df[df["year"] == latest_year]
|
||||||
|
|
||||||
|
# Primary: schools where KS2 data is non-null
|
||||||
|
primary_df = df_latest[df_latest["rwm_expected_pct"].notna()]
|
||||||
|
# Secondary: schools where KS4 data is non-null
|
||||||
|
secondary_df = df_latest[df_latest["attainment_8_score"].notna()]
|
||||||
|
|
||||||
|
latest_primary = _means(primary_df, ks2_metrics)
|
||||||
|
latest_secondary = _means(secondary_df, ks4_metrics)
|
||||||
|
|
||||||
|
# Per-year KS2 primary averages: use official DfE figures from the mart table.
|
||||||
|
# Per-year KS4 secondary averages: computed from our dataset (no DfE dataset yet).
|
||||||
|
from .database import SessionLocal
|
||||||
|
from .models import Ks2NationalAverage
|
||||||
|
|
||||||
|
by_year = []
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
nat_rows = db.query(Ks2NationalAverage).order_by(Ks2NationalAverage.year).all()
|
||||||
|
# Build a lookup of computed secondary averages per year as fallback
|
||||||
|
secondary_by_year = {}
|
||||||
|
for yr in sorted(df["year"].dropna().unique()):
|
||||||
|
yr = int(yr)
|
||||||
|
df_yr = df[df["year"] == yr]
|
||||||
|
secondary_by_year[yr] = _means(
|
||||||
|
df_yr[df_yr["attainment_8_score"].notna()], ks4_metrics
|
||||||
|
)
|
||||||
|
# Merge: official KS2 figures + computed KS4 figures per year
|
||||||
|
ks2_years = {r.year for r in nat_rows}
|
||||||
|
all_years = sorted(ks2_years | set(secondary_by_year.keys()))
|
||||||
|
nat_lookup = {r.year: r for r in nat_rows}
|
||||||
|
for yr in all_years:
|
||||||
|
primary_yr: dict = {}
|
||||||
|
if yr in nat_lookup:
|
||||||
|
r = nat_lookup[yr]
|
||||||
|
for col in ks2_metrics:
|
||||||
|
val = getattr(r, col, None)
|
||||||
|
if val is not None:
|
||||||
|
primary_yr[col] = val
|
||||||
|
by_year.append({
|
||||||
|
"year": yr,
|
||||||
|
"primary": primary_yr,
|
||||||
|
"secondary": secondary_by_year.get(yr, {}),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Update latest_primary with official DfE figure for the latest year if available
|
||||||
|
if by_year:
|
||||||
|
latest_official = next((e["primary"] for e in reversed(by_year) if e["primary"]), None)
|
||||||
|
if latest_official:
|
||||||
|
latest_primary = latest_official
|
||||||
|
|
||||||
|
return {
|
||||||
|
"year": latest_year,
|
||||||
|
"primary": latest_primary,
|
||||||
|
"secondary": latest_secondary,
|
||||||
|
"by_year": by_year,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -500,7 +767,7 @@ async def get_filter_options(request: Request):
|
|||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_available_metrics(request: Request):
|
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.
|
This is the single source of truth for metric definitions.
|
||||||
Frontend should consume this to avoid duplication.
|
Frontend should consume this to avoid duplication.
|
||||||
@@ -519,7 +786,7 @@ async def get_available_metrics(request: Request):
|
|||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_rankings(
|
async def get_rankings(
|
||||||
request: Request,
|
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(
|
year: Optional[int] = Query(
|
||||||
None, description="Specific year (defaults to most recent)", ge=2000, le=2100
|
None, description="Specific year (defaults to most recent)", ge=2000, le=2100
|
||||||
),
|
),
|
||||||
@@ -527,8 +794,11 @@ async def get_rankings(
|
|||||||
local_authority: Optional[str] = Query(
|
local_authority: Optional[str] = Query(
|
||||||
None, description="Filter by local authority", max_length=100
|
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
|
# Sanitize local authority input
|
||||||
local_authority = sanitize_search_input(local_authority)
|
local_authority = sanitize_search_input(local_authority)
|
||||||
|
|
||||||
@@ -556,6 +826,12 @@ async def get_rankings(
|
|||||||
if local_authority:
|
if local_authority:
|
||||||
df = df[df["local_authority"].str.lower() == local_authority.lower()]
|
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)
|
# Sort and rank (exclude rows with no data for this metric)
|
||||||
df = df.dropna(subset=[metric])
|
df = df.dropna(subset=[metric])
|
||||||
total = len(df)
|
total = len(df)
|
||||||
@@ -585,7 +861,7 @@ async def get_data_info(request: Request):
|
|||||||
if db_info["total_schools"] == 0:
|
if db_info["total_schools"] == 0:
|
||||||
return {
|
return {
|
||||||
"status": "no_data",
|
"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",
|
"data_source": "PostgreSQL",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,10 +875,10 @@ async def get_data_info(request: Request):
|
|||||||
"data_source": "PostgreSQL",
|
"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 = {
|
schools_per_year = {
|
||||||
str(int(k)): int(v)
|
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 = {
|
la_counts = {
|
||||||
str(k): int(v)
|
str(k): int(v)
|
||||||
@@ -635,56 +911,6 @@ async def reload_data(
|
|||||||
return {"status": "reloaded"}
|
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 +933,26 @@ async def robots_txt():
|
|||||||
@app.get("/sitemap.xml")
|
@app.get("/sitemap.xml")
|
||||||
async def sitemap_xml():
|
async def sitemap_xml():
|
||||||
"""Serve sitemap.xml for search engine indexing."""
|
"""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)
|
# 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
|
rate_limit_burst: int = 10 # Allow burst of requests
|
||||||
max_request_size: int = 1024 * 1024 # 1MB max request size
|
max_request_size: int = 1024 * 1024 # 1MB max request size
|
||||||
|
|
||||||
|
# Typesense
|
||||||
|
typesense_url: str = "http://localhost:8108"
|
||||||
|
typesense_api_key: str = ""
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
|
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
|
||||||
|
|
||||||
|
|||||||
+365
-582
File diff suppressed because it is too large
Load Diff
+8
-109
@@ -1,36 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
Database connection setup using SQLAlchemy.
|
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 contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
# Create engine
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
settings.database_url,
|
settings.database_url,
|
||||||
pool_size=10,
|
pool_size=10,
|
||||||
max_overflow=20,
|
max_overflow=20,
|
||||||
pool_pre_ping=True, # Verify connections before use
|
pool_pre_ping=True,
|
||||||
echo=False, # Set to True for SQL debugging
|
echo=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Session factory
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
# Base class for models
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""
|
"""Dependency for FastAPI routes."""
|
||||||
Dependency for FastAPI routes to get a database session.
|
|
||||||
"""
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
@@ -40,10 +34,7 @@ def get_db():
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_db_session():
|
def get_db_session():
|
||||||
"""
|
"""Context manager for non-FastAPI contexts."""
|
||||||
Context manager for database sessions.
|
|
||||||
Use in non-FastAPI contexts (scripts, etc).
|
|
||||||
"""
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
@@ -53,95 +44,3 @@ def get_db_session():
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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
|
|
||||||
|
|
||||||
|
|||||||
+163
-332
@@ -1,408 +1,239 @@
|
|||||||
"""
|
"""
|
||||||
SQLAlchemy database models for school data.
|
SQLAlchemy models — all tables live in the marts schema, built by dbt.
|
||||||
Normalized schema with separate tables for schools and yearly results.
|
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
|
from .database import Base
|
||||||
|
|
||||||
|
MARTS = {"schema": "marts"}
|
||||||
|
|
||||||
class School(Base):
|
|
||||||
"""
|
class DimSchool(Base):
|
||||||
Core school information - relatively static data.
|
"""Canonical school dimension — one row per active URN."""
|
||||||
"""
|
__tablename__ = "dim_school"
|
||||||
__tablename__ = "schools"
|
__table_args__ = MARTS
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
urn = Column(Integer, unique=True, nullable=False, index=True)
|
|
||||||
school_name = Column(String(255), nullable=False)
|
school_name = Column(String(255), nullable=False)
|
||||||
local_authority = Column(String(100))
|
phase = Column(String(100))
|
||||||
local_authority_code = Column(Integer)
|
|
||||||
school_type = Column(String(100))
|
school_type = Column(String(100))
|
||||||
school_type_code = Column(String(10))
|
academy_trust_name = Column(String(255))
|
||||||
religious_denomination = Column(String(100))
|
academy_trust_uid = Column(String(20))
|
||||||
|
religious_character = Column(String(100))
|
||||||
|
gender = Column(String(20))
|
||||||
age_range = Column(String(20))
|
age_range = Column(String(20))
|
||||||
|
capacity = Column(Integer)
|
||||||
# Address
|
total_pupils = Column(Integer)
|
||||||
address1 = Column(String(255))
|
headteacher_name = Column(String(200))
|
||||||
address2 = Column(String(255))
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
town = Column(String(100))
|
||||||
postcode = Column(String(20), index=True)
|
county = Column(String(100))
|
||||||
|
postcode = Column(String(20))
|
||||||
# Geocoding (cached)
|
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)
|
latitude = Column(Float)
|
||||||
longitude = Column(Float)
|
longitude = Column(Float)
|
||||||
|
# geom is a PostGIS geometry — not mapped to SQLAlchemy (accessed via raw SQL)
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
class SchoolResult(Base):
|
class KS2Performance(Base):
|
||||||
"""
|
"""KS2 attainment — one row per URN per year (includes predecessor data)."""
|
||||||
Yearly KS2 results for a school.
|
__tablename__ = "fact_ks2_performance"
|
||||||
Each school can have multiple years of results.
|
__table_args__ = (
|
||||||
"""
|
Index("ix_ks2_urn_year", "urn", "year"),
|
||||||
__tablename__ = "school_results"
|
MARTS,
|
||||||
|
)
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
school_id = Column(Integer, ForeignKey("schools.id", ondelete="CASCADE"), nullable=False)
|
urn = Column(Integer, primary_key=True)
|
||||||
year = Column(Integer, nullable=False, index=True)
|
year = Column(Integer, primary_key=True)
|
||||||
|
source_urn = Column(Integer)
|
||||||
# Pupil numbers
|
|
||||||
total_pupils = Column(Integer)
|
total_pupils = Column(Integer)
|
||||||
eligible_pupils = Column(Integer)
|
eligible_pupils = Column(Integer)
|
||||||
|
# Core attainment
|
||||||
# Core KS2 metrics - Expected Standard
|
|
||||||
rwm_expected_pct = Column(Float)
|
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)
|
rwm_high_pct = Column(Float)
|
||||||
|
reading_expected_pct = Column(Float)
|
||||||
reading_high_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_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_avg_score = Column(Float)
|
||||||
|
maths_progress = Column(Float)
|
||||||
|
gps_expected_pct = Column(Float)
|
||||||
|
gps_high_pct = Column(Float)
|
||||||
gps_avg_score = Column(Float)
|
gps_avg_score = Column(Float)
|
||||||
|
science_expected_pct = Column(Float)
|
||||||
# School Context
|
# 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)
|
disadvantaged_pct = Column(Float)
|
||||||
eal_pct = Column(Float)
|
eal_pct = Column(Float)
|
||||||
sen_support_pct = Column(Float)
|
sen_support_pct = Column(Float)
|
||||||
sen_ehcp_pct = Column(Float)
|
sen_ehcp_pct = Column(Float)
|
||||||
stability_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
|
class FactOfstedInspection(Base):
|
||||||
rwm_expected_boys_pct = Column(Float)
|
"""Full Ofsted inspection history — one row per inspection."""
|
||||||
rwm_expected_girls_pct = Column(Float)
|
__tablename__ = "fact_ofsted_inspection"
|
||||||
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
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint('school_id', 'year', name='uq_school_year'),
|
Index("ix_ofsted_urn_date", "urn", "inspection_date"),
|
||||||
Index('ix_school_results_school_year', 'school_id', 'year'),
|
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)
|
urn = Column(Integer, primary_key=True)
|
||||||
inspection_date = Column(Date)
|
inspection_date = Column(Date, primary_key=True)
|
||||||
publication_date = Column(Date)
|
inspection_type = Column(String(100))
|
||||||
inspection_type = Column(String(100)) # Section 5 / Section 8 etc.
|
|
||||||
# Which inspection framework was used: 'OEIF' or 'ReportCard'
|
|
||||||
framework = Column(String(20))
|
framework = Column(String(20))
|
||||||
|
|
||||||
# --- OEIF grades (old framework, pre-Nov 2025) ---
|
|
||||||
# 1=Outstanding 2=Good 3=Requires improvement 4=Inadequate
|
|
||||||
overall_effectiveness = Column(Integer)
|
overall_effectiveness = Column(Integer)
|
||||||
quality_of_education = Column(Integer)
|
quality_of_education = Column(Integer)
|
||||||
behaviour_attitudes = Column(Integer)
|
behaviour_attitudes = Column(Integer)
|
||||||
personal_development = Column(Integer)
|
personal_development = Column(Integer)
|
||||||
leadership_management = Column(Integer)
|
leadership_management = Column(Integer)
|
||||||
early_years_provision = Column(Integer) # nullable — not all schools
|
early_years_provision = Column(Integer)
|
||||||
previous_overall = Column(Integer) # for trend display
|
sixth_form_provision = Column(Integer)
|
||||||
|
rc_safeguarding_met = Column(Boolean)
|
||||||
# --- 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
|
|
||||||
rc_inclusion = Column(Integer)
|
rc_inclusion = Column(Integer)
|
||||||
rc_curriculum_teaching = Column(Integer)
|
rc_curriculum_teaching = Column(Integer)
|
||||||
rc_achievement = Column(Integer)
|
rc_achievement = Column(Integer)
|
||||||
rc_attendance_behaviour = Column(Integer)
|
rc_attendance_behaviour = Column(Integer)
|
||||||
rc_personal_development = Column(Integer)
|
rc_personal_development = Column(Integer)
|
||||||
rc_leadership_governance = Column(Integer)
|
rc_leadership_governance = Column(Integer)
|
||||||
rc_early_years = Column(Integer) # nullable — not all schools
|
rc_early_years = Column(Integer)
|
||||||
rc_sixth_form = Column(Integer) # nullable — secondary only
|
rc_sixth_form = Column(Integer)
|
||||||
|
report_url = Column(Text)
|
||||||
def __repr__(self):
|
|
||||||
return f"<OfstedInspection(urn={self.urn}, framework={self.framework}, overall={self.overall_effectiveness})>"
|
|
||||||
|
|
||||||
|
|
||||||
class OfstedParentView(Base):
|
class FactParentView(Base):
|
||||||
"""Ofsted Parent View survey — latest per school. 14 questions, % saying Yes."""
|
"""Ofsted Parent View survey — latest per school."""
|
||||||
__tablename__ = "ofsted_parent_view"
|
__tablename__ = "fact_parent_view"
|
||||||
|
__table_args__ = MARTS
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
survey_date = Column(Date)
|
survey_date = Column(Date)
|
||||||
total_responses = Column(Integer)
|
total_responses = Column(Integer)
|
||||||
q_happy_pct = Column(Float) # My child is happy at this school
|
q_happy_pct = Column(Float)
|
||||||
q_safe_pct = Column(Float) # My child feels safe at this school
|
q_safe_pct = Column(Float)
|
||||||
q_bullying_pct = Column(Float) # School deals with bullying well
|
q_behaviour_pct = Column(Float)
|
||||||
q_communication_pct = Column(Float) # School keeps me informed
|
q_bullying_pct = Column(Float)
|
||||||
q_progress_pct = Column(Float) # My child does well / good progress
|
q_communication_pct = Column(Float)
|
||||||
q_teaching_pct = Column(Float) # Teaching is good
|
q_progress_pct = Column(Float)
|
||||||
q_information_pct = Column(Float) # I receive valuable info about progress
|
q_teaching_pct = Column(Float)
|
||||||
q_curriculum_pct = Column(Float) # Broad range of subjects taught
|
q_information_pct = Column(Float)
|
||||||
q_future_pct = Column(Float) # Prepares child well for the future
|
q_curriculum_pct = Column(Float)
|
||||||
q_leadership_pct = Column(Float) # Led and managed effectively
|
q_future_pct = Column(Float)
|
||||||
q_wellbeing_pct = Column(Float) # Supports wider personal development
|
q_leadership_pct = Column(Float)
|
||||||
q_behaviour_pct = Column(Float) # Pupils are well behaved
|
q_wellbeing_pct = Column(Float)
|
||||||
q_recommend_pct = Column(Float) # I would recommend this school
|
q_recommend_pct = Column(Float)
|
||||||
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})>"
|
|
||||||
|
|
||||||
|
|
||||||
class SchoolCensus(Base):
|
class FactAdmissions(Base):
|
||||||
"""Annual school census snapshot — class sizes and ethnicity breakdown."""
|
"""School admissions — one row per URN per year."""
|
||||||
__tablename__ = "school_census"
|
__tablename__ = "fact_admissions"
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
__table_args__ = (
|
__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)
|
urn = Column(Integer, primary_key=True)
|
||||||
year = 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)
|
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)
|
||||||
|
oversubscription_ratio = Column(Float)
|
||||||
oversubscribed = Column(Boolean)
|
oversubscribed = Column(Boolean)
|
||||||
|
admissions_policy = Column(String(100))
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_school_admissions_urn_year', 'urn', 'year'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<SchoolAdmissions(urn={self.urn}, year={self.year})>"
|
|
||||||
|
|
||||||
|
|
||||||
class SenDetail(Base):
|
class FactDeprivation(Base):
|
||||||
"""SEN primary need type breakdown — more granular than school_results context fields."""
|
"""IDACI deprivation index — one row per URN."""
|
||||||
__tablename__ = "sen_detail"
|
__tablename__ = "fact_deprivation"
|
||||||
|
__table_args__ = MARTS
|
||||||
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"
|
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
lsoa_code = Column(String(20))
|
lsoa_code = Column(String(20))
|
||||||
idaci_score = Column(Float) # 0–1, higher = more deprived
|
idaci_score = Column(Float)
|
||||||
idaci_decile = Column(Integer) # 1 = most deprived, 10 = least deprived
|
idaci_decile = Column(Integer)
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<SchoolDeprivation(urn={self.urn}, decile={self.idaci_decile})>"
|
|
||||||
|
|
||||||
|
|
||||||
class SchoolFinance(Base):
|
class FactFinance(Base):
|
||||||
"""FBIT financial benchmarking data."""
|
"""FBIT financial benchmarking — one row per URN per year."""
|
||||||
__tablename__ = "school_finance"
|
__tablename__ = "fact_finance"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_finance_urn_year", "urn", "year"),
|
||||||
|
MARTS,
|
||||||
|
)
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
year = Column(Integer, primary_key=True)
|
year = Column(Integer, primary_key=True)
|
||||||
per_pupil_spend = Column(Float) # £ total expenditure per pupil
|
per_pupil_spend = Column(Float)
|
||||||
staff_cost_pct = Column(Float) # % of budget on all staff
|
staff_cost_pct = Column(Float)
|
||||||
teacher_cost_pct = Column(Float) # % on teachers specifically
|
teacher_cost_pct = Column(Float)
|
||||||
support_staff_cost_pct = Column(Float)
|
support_staff_cost_pct = Column(Float)
|
||||||
premises_cost_pct = Column(Float)
|
premises_cost_pct = Column(Float)
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_school_finance_urn_year', 'urn', 'year'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
class Ks2NationalAverage(Base):
|
||||||
return f"<SchoolFinance(urn={self.urn}, year={self.year})>"
|
"""Official DfE KS2 national headline averages — one row per academic year."""
|
||||||
|
__tablename__ = "fact_ks2_national_averages"
|
||||||
|
__table_args__ = MARTS
|
||||||
# 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',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
year = Column(Integer, primary_key=True)
|
||||||
|
rwm_expected_pct = Column(Float)
|
||||||
|
rwm_high_pct = Column(Float)
|
||||||
|
reading_expected_pct = Column(Float)
|
||||||
|
reading_high_pct = Column(Float)
|
||||||
|
reading_avg_score = Column(Float)
|
||||||
|
writing_expected_pct = Column(Float)
|
||||||
|
writing_gd_pct = Column(Float)
|
||||||
|
maths_expected_pct = Column(Float)
|
||||||
|
maths_high_pct = Column(Float)
|
||||||
|
maths_avg_score = Column(Float)
|
||||||
|
gps_expected_pct = Column(Float)
|
||||||
|
gps_high_pct = Column(Float)
|
||||||
|
gps_avg_score = Column(Float)
|
||||||
|
science_expected_pct = Column(Float)
|
||||||
|
|||||||
+89
-10
@@ -142,7 +142,7 @@ NULL_VALUES = ["SUPP", "NE", "NA", "NP", "NEW", "LOW", ""]
|
|||||||
METRIC_DEFINITIONS = {
|
METRIC_DEFINITIONS = {
|
||||||
# Expected Standard
|
# Expected Standard
|
||||||
"rwm_expected_pct": {
|
"rwm_expected_pct": {
|
||||||
"name": "RWM Combined %",
|
"name": "Reading, Writing & Maths Combined %",
|
||||||
"short_name": "RWM %",
|
"short_name": "RWM %",
|
||||||
"description": "% meeting expected standard in reading, writing and maths",
|
"description": "% meeting expected standard in reading, writing and maths",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
@@ -185,9 +185,9 @@ METRIC_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
# Higher Standard
|
# Higher Standard
|
||||||
"rwm_high_pct": {
|
"rwm_high_pct": {
|
||||||
"name": "RWM Combined Higher %",
|
"name": "Reading, Writing & Maths Combined Higher %",
|
||||||
"short_name": "RWM Higher %",
|
"short_name": "RWM Higher %",
|
||||||
"description": "% achieving higher standard in RWM combined",
|
"description": "% achieving higher standard in reading, writing & maths combined",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "higher",
|
"category": "higher",
|
||||||
},
|
},
|
||||||
@@ -265,28 +265,28 @@ METRIC_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
# Gender Performance
|
# Gender Performance
|
||||||
"rwm_expected_boys_pct": {
|
"rwm_expected_boys_pct": {
|
||||||
"name": "RWM Expected % (Boys)",
|
"name": "Reading, Writing & Maths Expected % (Boys)",
|
||||||
"short_name": "Boys RWM %",
|
"short_name": "Boys RWM %",
|
||||||
"description": "% of boys meeting expected standard",
|
"description": "% of boys meeting expected standard",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "gender",
|
"category": "gender",
|
||||||
},
|
},
|
||||||
"rwm_expected_girls_pct": {
|
"rwm_expected_girls_pct": {
|
||||||
"name": "RWM Expected % (Girls)",
|
"name": "Reading, Writing & Maths Expected % (Girls)",
|
||||||
"short_name": "Girls RWM %",
|
"short_name": "Girls RWM %",
|
||||||
"description": "% of girls meeting expected standard",
|
"description": "% of girls meeting expected standard",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "gender",
|
"category": "gender",
|
||||||
},
|
},
|
||||||
"rwm_high_boys_pct": {
|
"rwm_high_boys_pct": {
|
||||||
"name": "RWM Higher % (Boys)",
|
"name": "Reading, Writing & Maths Higher % (Boys)",
|
||||||
"short_name": "Boys Higher %",
|
"short_name": "Boys Higher %",
|
||||||
"description": "% of boys at higher standard",
|
"description": "% of boys at higher standard",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "gender",
|
"category": "gender",
|
||||||
},
|
},
|
||||||
"rwm_high_girls_pct": {
|
"rwm_high_girls_pct": {
|
||||||
"name": "RWM Higher % (Girls)",
|
"name": "Reading, Writing & Maths Higher % (Girls)",
|
||||||
"short_name": "Girls Higher %",
|
"short_name": "Girls Higher %",
|
||||||
"description": "% of girls at higher standard",
|
"description": "% of girls at higher standard",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
@@ -294,14 +294,14 @@ METRIC_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
# Disadvantaged Performance
|
# Disadvantaged Performance
|
||||||
"rwm_expected_disadvantaged_pct": {
|
"rwm_expected_disadvantaged_pct": {
|
||||||
"name": "RWM Expected % (Disadvantaged)",
|
"name": "Reading, Writing & Maths Expected % (Disadvantaged)",
|
||||||
"short_name": "Disadvantaged %",
|
"short_name": "Disadvantaged %",
|
||||||
"description": "% of disadvantaged pupils meeting expected",
|
"description": "% of disadvantaged pupils meeting expected",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
"category": "equity",
|
"category": "equity",
|
||||||
},
|
},
|
||||||
"rwm_expected_non_disadvantaged_pct": {
|
"rwm_expected_non_disadvantaged_pct": {
|
||||||
"name": "RWM Expected % (Non-Disadvantaged)",
|
"name": "Reading, Writing & Maths Expected % (Non-Disadvantaged)",
|
||||||
"short_name": "Non-Disadv %",
|
"short_name": "Non-Disadv %",
|
||||||
"description": "% of non-disadvantaged pupils meeting expected",
|
"description": "% of non-disadvantaged pupils meeting expected",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
@@ -381,7 +381,7 @@ METRIC_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
# 3-Year Averages
|
# 3-Year Averages
|
||||||
"rwm_expected_3yr_pct": {
|
"rwm_expected_3yr_pct": {
|
||||||
"name": "RWM Expected % (3-Year Avg)",
|
"name": "Reading, Writing & Maths Expected % (3-Year Avg)",
|
||||||
"short_name": "RWM 3yr %",
|
"short_name": "RWM 3yr %",
|
||||||
"description": "3-year average % meeting expected",
|
"description": "3-year average % meeting expected",
|
||||||
"type": "percentage",
|
"type": "percentage",
|
||||||
@@ -401,6 +401,70 @@ METRIC_DEFINITIONS = {
|
|||||||
"type": "score",
|
"type": "score",
|
||||||
"category": "trends",
|
"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
|
# Ranking columns to include in rankings response
|
||||||
@@ -456,6 +520,16 @@ RANKING_COLUMNS = [
|
|||||||
"rwm_expected_3yr_pct",
|
"rwm_expected_3yr_pct",
|
||||||
"reading_avg_3yr",
|
"reading_avg_3yr",
|
||||||
"maths_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
|
# School listing columns
|
||||||
@@ -469,6 +543,11 @@ SCHOOL_COLUMNS = [
|
|||||||
"postcode",
|
"postcode",
|
||||||
"religious_denomination",
|
"religious_denomination",
|
||||||
"age_range",
|
"age_range",
|
||||||
|
"gender",
|
||||||
|
"admissions_policy",
|
||||||
|
"ofsted_grade",
|
||||||
|
"ofsted_date",
|
||||||
|
"ofsted_framework",
|
||||||
"latitude",
|
"latitude",
|
||||||
"longitude",
|
"longitude",
|
||||||
]
|
]
|
||||||
|
|||||||
Vendored
BIN
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
|
|
||||||
|
@@ -7,9 +7,7 @@
|
|||||||
# ADMIN_API_KEY — Backend admin API key
|
# ADMIN_API_KEY — Backend admin API key
|
||||||
# TYPESENSE_API_KEY — Typesense admin API key
|
# TYPESENSE_API_KEY — Typesense admin API key
|
||||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||||
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
# 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:
|
services:
|
||||||
|
|
||||||
@@ -103,87 +101,6 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
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 + UI ───────────────────────────────────────────
|
||||||
airflow-api-server:
|
airflow-api-server:
|
||||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
|
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
|
||||||
@@ -282,7 +199,5 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
kestra_storage:
|
|
||||||
supplementary_data:
|
|
||||||
typesense_data:
|
typesense_data:
|
||||||
airflow_logs:
|
airflow_logs:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
POSTGRES_USER: schoolcompare
|
POSTGRES_USER: schoolcompare
|
||||||
POSTGRES_PASSWORD: schoolcompare
|
POSTGRES_PASSWORD: schoolcompare
|
||||||
POSTGRES_DB: schoolcompare
|
POSTGRES_DB: schoolcompare
|
||||||
|
POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
@@ -119,6 +120,8 @@ services:
|
|||||||
PG_DATABASE: schoolcompare
|
PG_DATABASE: schoolcompare
|
||||||
TYPESENSE_URL: http://typesense:8108
|
TYPESENSE_URL: http://typesense:8108
|
||||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:-changeme}
|
||||||
|
BACKEND_URL: http://backend:80
|
||||||
|
ADMIN_API_KEY: ${ADMIN_API_KEY:-changeme}
|
||||||
volumes:
|
volumes:
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
-2530
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
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
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
### Rankings Page
|
### Rankings Page
|
||||||
- [ ] Rankings page loads with SSR
|
- [ ] Rankings page loads with SSR
|
||||||
- [ ] Default metric displays (RWM Expected)
|
- [ ] Default metric displays (Reading, Writing & Maths Expected)
|
||||||
- [ ] Metric selector updates rankings
|
- [ ] Metric selector updates rankings
|
||||||
- [ ] Area filter updates rankings
|
- [ ] Area filter updates rankings
|
||||||
- [ ] Year filter updates rankings
|
- [ ] Year filter updates rankings
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
calculateTrend,
|
calculateTrend,
|
||||||
isValidPostcode,
|
isValidPostcode,
|
||||||
debounce,
|
debounce,
|
||||||
|
buildOfstedListBadge,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
|
|
||||||
describe('formatPercentage', () => {
|
describe('formatPercentage', () => {
|
||||||
@@ -102,3 +103,41 @@ describe('debounce', () => {
|
|||||||
|
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildOfstedListBadge', () => {
|
||||||
|
it('returns grade word + year for OEIF Outstanding', () => {
|
||||||
|
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
|
||||||
|
expect(badge.label).toBe('Outstanding · 2023');
|
||||||
|
expect(badge.cssClass).toBe('ofsted1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns grade word for each OEIF grade', () => {
|
||||||
|
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: '2022-05-01' }).label).toBe('Good · 2022');
|
||||||
|
expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021');
|
||||||
|
expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_date: '2020-03-01' }).label).toBe('Inadequate · 2020');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns grade word without year when date is missing', () => {
|
||||||
|
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: null });
|
||||||
|
expect(badge.label).toBe('Good');
|
||||||
|
expect(badge.cssClass).toBe('ofsted2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Report Card badge when framework is ReportCard', () => {
|
||||||
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2025-11-01', ofsted_framework: 'ReportCard' });
|
||||||
|
expect(badge.label).toBe('Report Card · 2025');
|
||||||
|
expect(badge.cssClass).toBe('ofstedRc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pending badge when no grade and no ReportCard framework', () => {
|
||||||
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: null });
|
||||||
|
expect(badge.label).toBe('Not yet inspected');
|
||||||
|
expect(badge.cssClass).toBe('ofstedPending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pending badge when all fields are undefined', () => {
|
||||||
|
const badge = buildOfstedListBadge({});
|
||||||
|
expect(badge.label).toBe('Not yet inspected');
|
||||||
|
expect(badge.cssClass).toBe('ofstedPending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,6 +58,23 @@
|
|||||||
|
|
||||||
--transition: 0.2s ease;
|
--transition: 0.2s ease;
|
||||||
--transition-slow: 0.4s 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -274,6 +291,8 @@ body {
|
|||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view {
|
.view {
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ const playfairDisplay = Playfair_Display({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: 'SchoolCompare | Compare Primary School Performance',
|
default: 'SchoolCompare | Compare School Performance',
|
||||||
template: '%s | SchoolCompare',
|
template: '%s | SchoolCompare',
|
||||||
},
|
},
|
||||||
description: 'Compare primary school KS2 performance across England',
|
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||||
keywords: 'school comparison, KS2 results, primary school performance, England schools, SATs results',
|
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||||
authors: [{ name: 'SchoolCompare' }],
|
authors: [{ name: 'SchoolCompare' }],
|
||||||
manifest: '/manifest.json',
|
manifest: '/manifest.json',
|
||||||
icons: {
|
icons: {
|
||||||
@@ -37,15 +37,15 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: 'website',
|
||||||
title: 'SchoolCompare | Compare Primary School Performance',
|
title: 'SchoolCompare | Compare School Performance',
|
||||||
description: 'Compare primary school KS2 performance across England',
|
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||||
url: 'https://schoolcompare.co.uk',
|
url: 'https://schoolcompare.co.uk',
|
||||||
siteName: 'SchoolCompare',
|
siteName: 'SchoolCompare',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary',
|
card: 'summary',
|
||||||
title: 'SchoolCompare | Compare Primary School Performance',
|
title: 'SchoolCompare | Compare School Performance',
|
||||||
description: 'Compare primary school KS2 performance across England',
|
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -11,15 +11,20 @@ interface HomePageProps {
|
|||||||
search?: string;
|
search?: string;
|
||||||
local_authority?: string;
|
local_authority?: string;
|
||||||
school_type?: string;
|
school_type?: string;
|
||||||
|
phase?: string;
|
||||||
page?: string;
|
page?: string;
|
||||||
postcode?: string;
|
postcode?: string;
|
||||||
radius?: string;
|
radius?: string;
|
||||||
|
sort?: string;
|
||||||
|
gender?: string;
|
||||||
|
admissions_policy?: string;
|
||||||
|
has_sixth_form?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Home',
|
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)
|
// Force dynamic rendering (no static generation at build time)
|
||||||
@@ -38,7 +43,11 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
params.search ||
|
params.search ||
|
||||||
params.local_authority ||
|
params.local_authority ||
|
||||||
params.school_type ||
|
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
|
// Fetch data on server with error handling
|
||||||
@@ -52,10 +61,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
search: params.search,
|
search: params.search,
|
||||||
local_authority: params.local_authority,
|
local_authority: params.local_authority,
|
||||||
school_type: params.school_type,
|
school_type: params.school_type,
|
||||||
|
phase: params.phase,
|
||||||
postcode: params.postcode,
|
postcode: params.postcode,
|
||||||
radius,
|
radius,
|
||||||
page,
|
page,
|
||||||
page_size: 50,
|
page_size: 50,
|
||||||
|
gender: params.gender,
|
||||||
|
admissions_policy: params.admissions_policy,
|
||||||
|
has_sixth_form: params.has_sixth_form,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Empty state by default
|
// Empty state by default
|
||||||
@@ -65,7 +78,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={schoolsData}
|
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}
|
totalSchools={dataInfo?.total_schools ?? null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -76,7 +89,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
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}
|
totalSchools={null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,22 +12,24 @@ interface RankingsPageProps {
|
|||||||
metric?: string;
|
metric?: string;
|
||||||
local_authority?: string;
|
local_authority?: string;
|
||||||
year?: string;
|
year?: string;
|
||||||
|
phase?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'School Rankings',
|
title: 'School Rankings',
|
||||||
description: 'Top-ranked primary schools by KS2 performance across England',
|
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
||||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, school league tables',
|
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Force dynamic rendering
|
// Force dynamic rendering
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
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;
|
const year = yearParam ? parseInt(yearParam) : undefined;
|
||||||
|
|
||||||
// Fetch rankings data with error handling
|
// Fetch rankings data with error handling
|
||||||
@@ -38,6 +40,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
|||||||
local_authority,
|
local_authority,
|
||||||
year,
|
year,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
|
phase,
|
||||||
}),
|
}),
|
||||||
fetchFilters(),
|
fetchFilters(),
|
||||||
fetchMetrics(),
|
fetchMetrics(),
|
||||||
@@ -49,11 +52,12 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
|||||||
return (
|
return (
|
||||||
<RankingsView
|
<RankingsView
|
||||||
rankings={rankingsResponse?.rankings || []}
|
rankings={rankingsResponse?.rankings || []}
|
||||||
filters={filtersResponse || { local_authorities: [], school_types: [], years: [] }}
|
filters={filtersResponse || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||||
metrics={metricsArray}
|
metrics={metricsArray}
|
||||||
selectedMetric={metric}
|
selectedMetric={metric}
|
||||||
selectedArea={local_authority}
|
selectedArea={local_authority}
|
||||||
selectedYear={year}
|
selectedYear={year}
|
||||||
|
selectedPhase={phase}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -63,11 +67,12 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
|||||||
return (
|
return (
|
||||||
<RankingsView
|
<RankingsView
|
||||||
rankings={[]}
|
rankings={[]}
|
||||||
filters={{ local_authorities: [], school_types: [], years: [] }}
|
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||||
metrics={[]}
|
metrics={[]}
|
||||||
selectedMetric={metric}
|
selectedMetric={metric}
|
||||||
selectedArea={local_authority}
|
selectedArea={local_authority}
|
||||||
selectedYear={year}
|
selectedYear={year}
|
||||||
|
selectedPhase={phase}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
ChartOptions,
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import type { ComparisonData } from '@/lib/types';
|
import type { ComparisonData } from '@/lib/types';
|
||||||
import { CHART_COLORS } from '@/lib/utils';
|
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
|
||||||
|
|
||||||
// Register Chart.js components
|
// Register Chart.js components
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -68,7 +68,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
|
|||||||
});
|
});
|
||||||
|
|
||||||
const chartData = {
|
const chartData = {
|
||||||
labels: years.map(String),
|
labels: years.map(formatAcademicYear),
|
||||||
datasets,
|
datasets,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,18 +23,12 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
background: var(--bg-accent, #1a1612);
|
background: var(--bg-primary, #faf7f2);
|
||||||
color: var(--text-inverse, #faf7f2);
|
color: var(--text-primary, #2c2420);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 10px 30px rgba(26, 22, 18, 0.3);
|
box-shadow: 0 8px 32px rgba(44, 36, 32, 0.18), 0 2px 8px rgba(44, 36, 32, 0.08);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border-color, #e8ddd4);
|
||||||
min-width: 260px;
|
min-width: 280px;
|
||||||
}
|
|
||||||
|
|
||||||
.toastInfo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastBadge {
|
.toastBadge {
|
||||||
@@ -48,38 +42,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastHeader {
|
.toastHeader {
|
||||||
@@ -93,10 +56,19 @@
|
|||||||
margin-bottom: 0;
|
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 {
|
.collapseBtn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: rgba(250, 247, 242, 0.6);
|
color: var(--text-muted, #8a7a72);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -107,16 +79,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.collapseBtn:hover {
|
.collapseBtn:hover {
|
||||||
color: var(--text-inverse, #faf7f2);
|
color: var(--text-primary, #2c2420);
|
||||||
}
|
|
||||||
|
|
||||||
.toastTitle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-inverse, #faf7f2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolList {
|
.schoolList {
|
||||||
@@ -124,8 +87,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
max-height: 120px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolItem {
|
.schoolItem {
|
||||||
@@ -133,14 +94,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.25rem 0.375rem;
|
padding: 0.3rem 0.5rem;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--bg-secondary, #f3ede4);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm, 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolName {
|
.schoolName {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-inverse, #faf7f2);
|
color: var(--text-primary, #2c2420);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -150,7 +111,7 @@
|
|||||||
.removeSchoolBtn {
|
.removeSchoolBtn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: rgba(250, 247, 242, 0.5);
|
color: var(--text-muted, #8a7a72);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
@@ -160,7 +121,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.removeSchoolBtn:hover {
|
.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) {
|
@media (max-width: 640px) {
|
||||||
@@ -179,4 +183,4 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import styles from './ComparisonToast.module.css';
|
|||||||
export function ComparisonToast() {
|
export function ComparisonToast() {
|
||||||
const { selectedSchools, clearAll, removeSchool } = useComparison();
|
const { selectedSchools, clearAll, removeSchool } = useComparison();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,7 +62,7 @@ export function ComparisonToast() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.toastActions}>
|
<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>
|
<Link href="/compare" className={styles.btnCompare}>Compare Now</Link>
|
||||||
</div>
|
</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 */
|
/* Metric Selector */
|
||||||
.metricSelector {
|
.metricSelector {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
@@ -260,12 +301,15 @@
|
|||||||
|
|
||||||
.tableWrapper {
|
.tableWrapper {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable {
|
.comparisonTable {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +323,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -290,6 +335,24 @@
|
|||||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary, #5c564d);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky first column (Year) so labels remain visible while scrolling */
|
||||||
|
.comparisonTable th:first-child,
|
||||||
|
.comparisonTable td:first-child {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 2px 0 4px -2px rgba(26, 22, 18, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparisonTable thead th:first-child {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparisonTable tbody tr:hover td:first-child {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable tbody tr:last-child td {
|
.comparisonTable tbody tr:last-child td {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ComparisonView Component
|
* ComparisonView Component
|
||||||
* Client-side comparison interface with charts and tables
|
* Client-side comparison interface with phase tabs, charts, and tables
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
@@ -12,11 +12,30 @@ import { ComparisonChart } from './ComparisonChart';
|
|||||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||||
import type { ComparisonData, MetricDefinition } from '@/lib/types';
|
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
||||||
import { fetchComparison } from '@/lib/api';
|
import { fetchComparison } from '@/lib/api';
|
||||||
import styles from './ComparisonView.module.css';
|
import styles from './ComparisonView.module.css';
|
||||||
|
|
||||||
|
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||||
|
const SECONDARY_CATEGORIES = ['gcse'];
|
||||||
|
|
||||||
|
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||||
|
{ label: 'Expected Standard', category: 'expected' },
|
||||||
|
{ label: 'Higher Standard', category: 'higher' },
|
||||||
|
{ label: 'Progress Scores', category: 'progress' },
|
||||||
|
{ label: 'Average Scores', category: 'average' },
|
||||||
|
{ label: 'Gender Performance', category: 'gender' },
|
||||||
|
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||||
|
{ label: 'School Context', category: 'context' },
|
||||||
|
{ label: 'Absence', category: 'absence' },
|
||||||
|
{ label: '3-Year Trends', category: 'trends' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||||
|
{ label: 'GCSE Performance', category: 'gcse' },
|
||||||
|
];
|
||||||
|
|
||||||
interface ComparisonViewProps {
|
interface ComparisonViewProps {
|
||||||
initialData: Record<string, ComparisonData> | null;
|
initialData: Record<string, ComparisonData> | null;
|
||||||
initialUrns: number[];
|
initialUrns: number[];
|
||||||
@@ -39,6 +58,7 @@ export function ComparisonView({
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [comparisonData, setComparisonData] = useState(initialData);
|
const [comparisonData, setComparisonData] = useState(initialData);
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
const [shareConfirm, setShareConfirm] = useState(false);
|
||||||
|
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||||
|
|
||||||
// Seed context from initialData when component mounts and localStorage is empty
|
// Seed context from initialData when component mounts and localStorage is empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,6 +104,37 @@ export function ComparisonView({
|
|||||||
}
|
}
|
||||||
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
}, [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) => {
|
const handleMetricChange = (metric: string) => {
|
||||||
setSelectedMetric(metric);
|
setSelectedMetric(metric);
|
||||||
};
|
};
|
||||||
@@ -100,6 +151,12 @@ export function ComparisonView({
|
|||||||
} catch { /* fallback: do nothing */ }
|
} catch { /* fallback: do nothing */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPrimary = comparePhase === 'primary';
|
||||||
|
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||||
|
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||||
|
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
||||||
|
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
||||||
|
|
||||||
// Get metric definition
|
// Get metric definition
|
||||||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
||||||
const metricLabel = currentMetricDef?.label || 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
|
// Get years for table
|
||||||
const years =
|
const years =
|
||||||
comparisonData && Object.keys(comparisonData).length > 0
|
Object.keys(activeComparisonData).length > 0
|
||||||
? comparisonData[Object.keys(comparisonData)[0]].yearly_data.map((d) => d.year)
|
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,208 +225,206 @@ export function ComparisonView({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Metric Selector */}
|
{/* Phase Tabs */}
|
||||||
<section className={styles.metricSelector}>
|
<div className={styles.phaseTabs}>
|
||||||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
<button
|
||||||
Select Metric:
|
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||||
</label>
|
onClick={() => handlePhaseChange('primary')}
|
||||||
<select
|
|
||||||
id="metric-select"
|
|
||||||
value={selectedMetric}
|
|
||||||
onChange={(e) => handleMetricChange(e.target.value)}
|
|
||||||
className={styles.metricSelect}
|
|
||||||
>
|
>
|
||||||
<optgroup label="Expected Standard">
|
Primary ({primarySchools.length})
|
||||||
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
</button>
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
<button
|
||||||
))}
|
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||||
</optgroup>
|
onClick={() => handlePhaseChange('secondary')}
|
||||||
<optgroup label="Higher Standard">
|
>
|
||||||
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
Secondary ({secondarySchools.length})
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</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) => (
|
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
{currentMetricDef?.description && (
|
|
||||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Progress score explanation */}
|
{activeSchools.length === 0 ? (
|
||||||
{selectedMetric.includes('progress') && (
|
<EmptyState
|
||||||
<p className={styles.progressNote}>
|
title={`No ${comparePhase} schools in your comparison`}
|
||||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
message={`Add ${comparePhase} schools from search results to compare them here.`}
|
||||||
</p>
|
action={{
|
||||||
)}
|
label: '+ Add Schools',
|
||||||
|
onClick: () => setIsModalOpen(true),
|
||||||
{/* School Cards */}
|
}}
|
||||||
<section className={styles.schoolsSection}>
|
/>
|
||||||
<div className={styles.schoolsGrid}>
|
) : (
|
||||||
{selectedSchools.map((school, index) => (
|
<>
|
||||||
<div
|
{/* Metric Selector */}
|
||||||
key={school.urn}
|
<section className={styles.metricSelector}>
|
||||||
className={styles.schoolCard}
|
<label htmlFor="metric-select" className={styles.metricLabel}>
|
||||||
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
Select Metric:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="metric-select"
|
||||||
|
value={selectedMetric}
|
||||||
|
onChange={(e) => handleMetricChange(e.target.value)}
|
||||||
|
className={styles.metricSelect}
|
||||||
>
|
>
|
||||||
<button
|
{optgroups.map(({ label, category }) => {
|
||||||
onClick={() => handleRemoveSchool(school.urn)}
|
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||||
className={styles.removeButton}
|
if (groupMetrics.length === 0) return null;
|
||||||
aria-label={`Remove ${school.school_name}`}
|
return (
|
||||||
title="Remove from comparison"
|
<optgroup key={category} label={label}>
|
||||||
>
|
{groupMetrics.map((metric) => (
|
||||||
×
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||||
</button>
|
))}
|
||||||
<h2 className={styles.schoolName}>
|
</optgroup>
|
||||||
<a href={`/school/${school.urn}`}>{school.school_name}</a>
|
);
|
||||||
</h2>
|
})}
|
||||||
<div className={styles.schoolMeta}>
|
</select>
|
||||||
{school.local_authority && (
|
{currentMetricDef?.description && (
|
||||||
<span className={styles.metaItem}>{school.local_authority}</span>
|
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||||
)}
|
)}
|
||||||
{school.school_type && (
|
</section>
|
||||||
<span className={styles.metaItem}>{school.school_type}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Latest metric value */}
|
{/* Progress score explanation */}
|
||||||
{comparisonData && comparisonData[school.urn] && (
|
{selectedMetric.includes('progress') && (
|
||||||
<div className={styles.latestValue}>
|
<p className={styles.progressNote}>
|
||||||
<div className={styles.latestLabel}>{metricLabel}</div>
|
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
||||||
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_COLORS.length] }}>
|
</p>
|
||||||
<span
|
)}
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '10px',
|
|
||||||
height: '10px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: CHART_COLORS[index % CHART_COLORS.length],
|
|
||||||
marginRight: '0.4rem',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
const yearlyData = comparisonData[school.urn].yearly_data;
|
|
||||||
if (yearlyData.length === 0) return '-';
|
|
||||||
|
|
||||||
const latestData = yearlyData[yearlyData.length - 1];
|
{/* School Cards */}
|
||||||
const value = latestData[selectedMetric as keyof typeof latestData];
|
<section className={styles.schoolsSection}>
|
||||||
|
<div className={styles.schoolsGrid}>
|
||||||
if (value === null || value === undefined) return '-';
|
{activeSchools.map((school, index) => (
|
||||||
|
<div
|
||||||
// Format based on metric type
|
key={school.urn}
|
||||||
if (selectedMetric.includes('progress')) {
|
className={styles.schoolCard}
|
||||||
return formatProgress(value as number);
|
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
||||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
>
|
||||||
return formatPercentage(value as number);
|
<button
|
||||||
} else {
|
onClick={() => handleRemoveSchool(school.urn)}
|
||||||
return typeof value === 'number' ? value.toFixed(1) : String(value);
|
className={styles.removeButton}
|
||||||
}
|
aria-label={`Remove ${school.school_name}`}
|
||||||
})()}
|
title="Remove from comparison"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2 className={styles.schoolName}>
|
||||||
|
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
||||||
|
</h2>
|
||||||
|
<div className={styles.schoolMeta}>
|
||||||
|
{school.local_authority && (
|
||||||
|
<span className={styles.metaItem}>{school.local_authority}</span>
|
||||||
|
)}
|
||||||
|
{school.school_type && (
|
||||||
|
<span className={styles.metaItem}>{school.school_type}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Latest metric value */}
|
||||||
|
{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] }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: CHART_COLORS[index % CHART_COLORS.length],
|
||||||
|
marginRight: '0.4rem',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{(() => {
|
||||||
|
const yearlyData = activeComparisonData[school.urn].yearly_data;
|
||||||
|
if (yearlyData.length === 0) return '-';
|
||||||
|
|
||||||
|
const latestData = yearlyData[yearlyData.length - 1];
|
||||||
|
const value = latestData[selectedMetric as keyof typeof latestData];
|
||||||
|
|
||||||
|
if (value === null || value === undefined) return '-';
|
||||||
|
|
||||||
|
if (selectedMetric.includes('progress')) {
|
||||||
|
return formatProgress(value as number);
|
||||||
|
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||||
|
return formatPercentage(value as number);
|
||||||
|
} else {
|
||||||
|
return typeof value === 'number' ? value.toFixed(1) : String(value);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</section>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Comparison Chart */}
|
{/* Comparison Chart */}
|
||||||
{comparisonData && Object.keys(comparisonData).length > 0 ? (
|
{Object.keys(activeComparisonData).length > 0 ? (
|
||||||
<section className={styles.chartSection}>
|
<section className={styles.chartSection}>
|
||||||
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
||||||
<div className={styles.chartContainer}>
|
<div className={styles.chartContainer}>
|
||||||
<ComparisonChart
|
<ComparisonChart
|
||||||
comparisonData={comparisonData}
|
comparisonData={activeComparisonData}
|
||||||
metric={selectedMetric}
|
metric={selectedMetric}
|
||||||
metricLabel={metricLabel}
|
metricLabel={metricLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : selectedSchools.length > 0 ? (
|
) : activeSchools.length > 0 ? (
|
||||||
<section className={styles.chartSection}>
|
<section className={styles.chartSection}>
|
||||||
<LoadingSkeleton type="list" />
|
<LoadingSkeleton type="list" />
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Comparison Table */}
|
{/* Comparison Table */}
|
||||||
{comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && (
|
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
||||||
<section className={styles.tableSection}>
|
<section className={styles.tableSection}>
|
||||||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
||||||
<div className={styles.tableWrapper}>
|
<div className={styles.tableWrapper}>
|
||||||
<table className={styles.comparisonTable}>
|
<table className={styles.comparisonTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Year</th>
|
<th>Year</th>
|
||||||
{selectedSchools.map((school) => (
|
{activeSchools.map((school) => (
|
||||||
<th key={school.urn}>{school.school_name}</th>
|
<th key={school.urn}>{school.school_name}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{years.map((year) => (
|
{years.map((year) => (
|
||||||
<tr key={year}>
|
<tr key={year}>
|
||||||
<td className={styles.yearCell}>{year}</td>
|
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
||||||
{selectedSchools.map((school) => {
|
{activeSchools.map((school) => {
|
||||||
const schoolData = comparisonData[school.urn];
|
const schoolData = activeComparisonData[school.urn];
|
||||||
if (!schoolData) return <td key={school.urn}>-</td>;
|
if (!schoolData) return <td key={school.urn}>-</td>;
|
||||||
|
|
||||||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
||||||
if (!yearData) return <td key={school.urn}>-</td>;
|
if (!yearData) return <td key={school.urn}>-</td>;
|
||||||
|
|
||||||
const value = yearData[selectedMetric as keyof typeof yearData];
|
const value = yearData[selectedMetric as keyof typeof yearData];
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return <td key={school.urn}>-</td>;
|
return <td key={school.urn}>-</td>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format based on metric type
|
let displayValue: string;
|
||||||
let displayValue: string;
|
if (selectedMetric.includes('progress')) {
|
||||||
if (selectedMetric.includes('progress')) {
|
displayValue = formatProgress(value as number);
|
||||||
displayValue = formatProgress(value as number);
|
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
displayValue = formatPercentage(value as number);
|
||||||
displayValue = formatPercentage(value as number);
|
} else {
|
||||||
} else {
|
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
|
||||||
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return <td key={school.urn}>{displayValue}</td>;
|
return <td key={school.urn}>{displayValue}</td>;
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* School Search Modal */}
|
{/* School Search Modal */}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm {
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suffix {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.good {
|
||||||
|
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad {
|
||||||
|
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neutral {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* DeltaChip — small coloured chip showing how a value compares to a baseline.
|
||||||
|
*
|
||||||
|
* Example: <DeltaChip value={70} baseline={60} unit="pts" /> → "+10 pts"
|
||||||
|
*
|
||||||
|
* Colours (reuse globals.css status tokens):
|
||||||
|
* above baseline → statusGood (teal)
|
||||||
|
* below baseline → statusBad (coral)
|
||||||
|
* within ±tolerance → statusWarn (gold, "in line")
|
||||||
|
*/
|
||||||
|
|
||||||
|
import styles from './DeltaChip.module.css';
|
||||||
|
|
||||||
|
interface DeltaChipProps {
|
||||||
|
value: number | null | undefined;
|
||||||
|
baseline: number | null | undefined;
|
||||||
|
/** Unit suffix for the delta (e.g. "pts", "%") */
|
||||||
|
unit?: string;
|
||||||
|
/** Absolute delta below which the chip is treated as neutral */
|
||||||
|
tolerance?: number;
|
||||||
|
/** Override label when we want "vs national" under a number */
|
||||||
|
suffix?: string;
|
||||||
|
/** Smaller variant for inline use next to metric values */
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeltaChip({
|
||||||
|
value,
|
||||||
|
baseline,
|
||||||
|
unit = 'pts',
|
||||||
|
tolerance = 1,
|
||||||
|
suffix,
|
||||||
|
size = 'md',
|
||||||
|
}: DeltaChipProps) {
|
||||||
|
if (value == null || baseline == null) return null;
|
||||||
|
|
||||||
|
const delta = value - baseline;
|
||||||
|
const rounded = Math.round(delta);
|
||||||
|
|
||||||
|
let tone: 'good' | 'bad' | 'neutral';
|
||||||
|
if (Math.abs(delta) < tolerance) tone = 'neutral';
|
||||||
|
else if (delta > 0) tone = 'good';
|
||||||
|
else tone = 'bad';
|
||||||
|
|
||||||
|
const toneClass =
|
||||||
|
tone === 'good' ? styles.good : tone === 'bad' ? styles.bad : styles.neutral;
|
||||||
|
|
||||||
|
const sign = rounded > 0 ? '+' : '';
|
||||||
|
const label = `${sign}${rounded} ${unit}`.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`${styles.chip} ${toneClass} ${size === 'sm' ? styles.sm : ''}`}>
|
||||||
|
{label}
|
||||||
|
{suffix && <span className={styles.suffix}>{suffix}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,8 +32,12 @@
|
|||||||
padding: 1.25rem 2.5rem;
|
padding: 1.25rem 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heroMode .searchSection {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.searchSection {
|
.searchSection {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omniBoxContainer {
|
.omniBoxContainer {
|
||||||
@@ -84,30 +88,49 @@
|
|||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.625rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.625rem;
|
||||||
|
padding-top: 0.625rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterSelect {
|
.filterSelect {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 180px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.625rem 2.25rem 0.625rem 1rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.875rem;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
font-weight: 500;
|
||||||
border-radius: 6px;
|
font-family: inherit;
|
||||||
background: var(--bg-card, white);
|
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;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: var(--text-primary, #1a1612);
|
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 {
|
.filterSelect:focus {
|
||||||
border-color: var(--accent-coral, #e07256);
|
border-color: var(--accent-coral, #e07256);
|
||||||
|
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clearButton {
|
.clearButton {
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.4rem 1rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -130,6 +153,19 @@
|
|||||||
.filterSelect {
|
.filterSelect {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controlsRow {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsRow .advancedToggle {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlSelect {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.radiusWrapper {
|
.radiusWrapper {
|
||||||
@@ -140,17 +176,104 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.radiusLabel {
|
.radiusLabel {
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-secondary);
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #5a554d);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radiusSelect {
|
/* ── Controls row (radius + phase + advanced toggle) ─── */
|
||||||
padding: 0.375rem 0.75rem;
|
.controlsRow {
|
||||||
border: 1px solid var(--border-color, #e0ddd8);
|
display: flex;
|
||||||
border-radius: var(--radius-md);
|
align-items: center;
|
||||||
background: var(--bg-card);
|
gap: 0.5rem;
|
||||||
color: var(--text-secondary);
|
flex-wrap: wrap;
|
||||||
font-size: 0.875rem;
|
margin-top: 0.875rem;
|
||||||
cursor: pointer;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,106 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useTransition, useRef, useEffect } from 'react';
|
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
import { isValidPostcode } from '@/lib/utils';
|
import { isValidPostcode } from "@/lib/utils";
|
||||||
import type { Filters } from '@/lib/types';
|
import type { Filters, ResultFilters } from "@/lib/types";
|
||||||
import styles from './FilterBar.module.css';
|
import styles from "./FilterBar.module.css";
|
||||||
|
|
||||||
interface FilterBarProps {
|
interface FilterBarProps {
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
|
resultFilters?: ResultFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterBar({ filters, isHero }: FilterBarProps) {
|
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const currentSearch = searchParams.get('search') || '';
|
const currentSearch = searchParams.get("search") || "";
|
||||||
const currentPostcode = searchParams.get('postcode') || '';
|
const currentPostcode = searchParams.get("postcode") || "";
|
||||||
const currentRadius = searchParams.get('radius') || '1';
|
const currentRadius = searchParams.get("radius") || "1";
|
||||||
const initialOmniValue = currentPostcode || currentSearch;
|
const initialOmniValue = currentPostcode || currentSearch;
|
||||||
|
|
||||||
const [omniValue, setOmniValue] = useState(initialOmniValue);
|
const [omniValue, setOmniValue] = useState(initialOmniValue);
|
||||||
|
|
||||||
const currentLA = searchParams.get('local_authority') || '';
|
const currentLA = searchParams.get("local_authority") || "";
|
||||||
const currentType = searchParams.get('school_type') || '';
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Focus search on '/' or Ctrl+K, but not when typing in an input
|
if (
|
||||||
if ((e.key === '/' || (e.key === 'k' && (e.ctrlKey || e.metaKey))) &&
|
(e.key === "/" || (e.key === "k" && (e.ctrlKey || e.metaKey))) &&
|
||||||
document.activeElement?.tagName !== 'INPUT' &&
|
document.activeElement?.tagName !== "INPUT" &&
|
||||||
document.activeElement?.tagName !== 'TEXTAREA' &&
|
document.activeElement?.tagName !== "TEXTAREA" &&
|
||||||
document.activeElement?.tagName !== 'SELECT') {
|
document.activeElement?.tagName !== "SELECT"
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateURL = useCallback((updates: Record<string, string>) => {
|
const updateURL = useCallback(
|
||||||
const params = new URLSearchParams(searchParams);
|
(updates: Record<string, string>) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
Object.entries(updates).forEach(([key, value]) => {
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
if (value && value !== '') {
|
if (value && value !== "") {
|
||||||
params.set(key, value);
|
params.set(key, value);
|
||||||
} else {
|
} else {
|
||||||
params.delete(key);
|
params.delete(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
params.delete('page');
|
params.delete("page");
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`${pathname}?${params.toString()}`);
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
});
|
});
|
||||||
}, [searchParams, pathname, router]);
|
},
|
||||||
|
[searchParams, pathname, router],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!omniValue.trim()) {
|
if (!omniValue.trim()) {
|
||||||
updateURL({ search: '', postcode: '', radius: '' });
|
updateURL({ search: "", postcode: "", radius: "" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValidPostcode(omniValue)) {
|
if (isValidPostcode(omniValue)) {
|
||||||
updateURL({ postcode: omniValue.trim().toUpperCase(), radius: currentRadius || '1', search: '' });
|
updateURL({
|
||||||
|
postcode: omniValue.trim().toUpperCase(),
|
||||||
|
radius: currentRadius || "1",
|
||||||
|
search: "",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
updateURL({ search: omniValue.trim(), postcode: '', radius: '' });
|
updateURL({ search: omniValue.trim(), postcode: "", radius: "" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,16 +109,38 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
const handleClearFilters = () => {
|
||||||
setOmniValue('');
|
setOmniValue("");
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(pathname);
|
router.push(pathname);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
|
<div
|
||||||
|
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
||||||
|
>
|
||||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
||||||
<div className={styles.omniBoxContainer}>
|
<div className={styles.omniBoxContainer}>
|
||||||
<input
|
<input
|
||||||
@@ -97,67 +148,171 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
|||||||
type="search"
|
type="search"
|
||||||
value={omniValue}
|
value={omniValue}
|
||||||
onChange={(e) => setOmniValue(e.target.value)}
|
onChange={(e) => setOmniValue(e.target.value)}
|
||||||
placeholder="Search by school name or postcode (e.g., SW1A 1AA)..."
|
placeholder="School name or postcode"
|
||||||
className={styles.omniInput}
|
className={styles.omniInput}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className={`btn btn-primary ${styles.searchButton}`} disabled={isPending}>
|
<button
|
||||||
{isPending ? <div className={styles.spinner}></div> : 'Search'}
|
type="submit"
|
||||||
|
className={`btn btn-primary ${styles.searchButton}`}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{currentPostcode && (
|
|
||||||
<div className={styles.radiusWrapper}>
|
|
||||||
<label className={styles.radiusLabel}>Within:</label>
|
|
||||||
<select
|
|
||||||
value={currentRadius}
|
|
||||||
onChange={e => updateURL({ radius: e.target.value })}
|
|
||||||
className={styles.radiusSelect}
|
|
||||||
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>
|
</form>
|
||||||
|
|
||||||
<div className={styles.filters}>
|
{!isHero && (
|
||||||
<select
|
<>
|
||||||
value={currentLA}
|
<div className={styles.controlsRow}>
|
||||||
onChange={(e) => handleFilterChange('local_authority', e.target.value)}
|
{currentPostcode && (
|
||||||
className={styles.filterSelect}
|
<div className={styles.radiusControl}>
|
||||||
disabled={isPending}
|
<label className={styles.radiusLabel}>Within:</label>
|
||||||
>
|
<select
|
||||||
<option value="">All Local Authorities</option>
|
value={currentRadius}
|
||||||
{filters.local_authorities.map((la) => (
|
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||||
<option key={la} value={la}>
|
className={styles.controlSelect}
|
||||||
{la}
|
disabled={isPending}
|
||||||
</option>
|
>
|
||||||
))}
|
<option value="0.5">0.5 miles</option>
|
||||||
</select>
|
<option value="1">1 mile</option>
|
||||||
|
<option value="3">3 miles</option>
|
||||||
|
<option value="5">5 miles</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<select
|
{phaseOptions.length > 0 && (
|
||||||
value={currentType}
|
<select
|
||||||
onChange={(e) => handleFilterChange('school_type', e.target.value)}
|
value={currentPhase}
|
||||||
className={styles.filterSelect}
|
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||||
disabled={isPending}
|
className={styles.controlSelect}
|
||||||
>
|
disabled={isPending}
|
||||||
<option value="">All School Types</option>
|
>
|
||||||
{filters.school_types.map((type) => (
|
<option value="">All Phases</option>
|
||||||
<option key={type} value={type}>
|
{phaseOptions.map((p) => (
|
||||||
{type}
|
<option key={p} value={p.toLowerCase()}>
|
||||||
</option>
|
{p}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasActiveFilters && (
|
<button
|
||||||
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
|
type="button"
|
||||||
Clear Filters
|
className={styles.advancedToggle}
|
||||||
</button>
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
)}
|
>
|
||||||
</div>
|
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}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("local_authority", e.target.value)
|
||||||
|
}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">All Local Authorities</option>
|
||||||
|
{laOptions.map((la) => (
|
||||||
|
<option key={la} value={la}>
|
||||||
|
{la}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={currentType}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("school_type", e.target.value)
|
||||||
|
}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">All School Types</option>
|
||||||
|
{typeOptions.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function Footer() {
|
|||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.title}>SchoolCompare</h3>
|
<h3 className={styles.title}>SchoolCompare</h3>
|
||||||
<p className={styles.description}>
|
<p className={styles.description}>
|
||||||
Compare primary schools across England.
|
Compare primary and secondary schools across England.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */
|
/* View Toggle */
|
||||||
.viewToggle {
|
.viewToggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -111,7 +84,9 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 340px;
|
grid-template-columns: 1fr 340px;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
height: 480px;
|
height: calc(100vh - 280px);
|
||||||
|
min-height: 520px;
|
||||||
|
max-height: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapContainer {
|
.mapContainer {
|
||||||
@@ -308,16 +283,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.locationBannerWrapper {
|
.resultsHeader {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: flex-start;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.locationBanner {
|
.resultsHeaderActions {
|
||||||
padding: 0.5rem 0.75rem;
|
width: 100%;
|
||||||
font-size: 0.8125rem;
|
justify-content: space-between;
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewToggle {
|
.viewToggle {
|
||||||
@@ -509,6 +482,13 @@
|
|||||||
padding: 0 0 1rem;
|
padding: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resultsHeaderActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sortSelect {
|
.sortSelect {
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
border: 1px solid var(--border-color, #e0ddd8);
|
border: 1px solid var(--border-color, #e0ddd8);
|
||||||
@@ -549,3 +529,21 @@
|
|||||||
.chipRemove:hover {
|
.chipRemove:hover {
|
||||||
color: var(--text-primary);
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||||
import { FilterBar } from './FilterBar';
|
import { FilterBar } from './FilterBar';
|
||||||
import { SchoolRow } from './SchoolRow';
|
import { SchoolRow } from './SchoolRow';
|
||||||
|
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||||
import { SchoolMap } from './SchoolMap';
|
import { SchoolMap } from './SchoolMap';
|
||||||
import { Pagination } from './Pagination';
|
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||||
|
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||||
|
import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
||||||
import styles from './HomeView.module.css';
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
interface HomeViewProps {
|
interface HomeViewProps {
|
||||||
@@ -24,23 +26,102 @@ interface HomeViewProps {
|
|||||||
|
|
||||||
export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) {
|
export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||||||
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
||||||
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
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 [nationalAvgRwm, setNationalAvgRwm] = useState<number | null>(null);
|
||||||
|
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
||||||
|
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
||||||
|
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||||
|
|
||||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||||
const isLocationSearch = !!searchParams.get('postcode');
|
const isLocationSearch = !!searchParams.get('postcode');
|
||||||
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
|
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
|
// Close bottom sheet if we change views or search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedMapSchool(null);
|
setSelectedMapSchool(null);
|
||||||
}, [resultsView, searchParams]);
|
}, [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]);
|
||||||
|
|
||||||
|
// Fetch national averages (supplementary — never blocks render)
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNationalAverages()
|
||||||
|
.then(data => setNationalAvgRwm(data.primary?.rwm_expected_pct ?? null))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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_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 === '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 === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity);
|
||||||
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -51,20 +132,21 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
{/* Combined Hero + Search and Filters */}
|
{/* Combined Hero + Search and Filters */}
|
||||||
{!isSearchActive && (
|
{!isSearchActive && (
|
||||||
<div className={styles.heroSection}>
|
<div className={styles.heroSection}>
|
||||||
<h1 className={styles.heroTitle}>Compare Primary School Performance</h1>
|
<h1 className={styles.heroTitle}>Find Local Schools</h1>
|
||||||
<p className={styles.heroDescription}>Search and compare KS2 results for thousands of schools across England</p>
|
<p className={styles.heroDescription}>Compare school results (SATs and GCSE), for thousands of schools across England</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
isHero={!isSearchActive}
|
isHero={!isSearchActive}
|
||||||
|
resultFilters={initialSchools.result_filters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Discovery section shown on landing page before any search */}
|
{/* Discovery section shown on landing page before any search */}
|
||||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||||
<div className={styles.discoverySection}>
|
<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>
|
<p className={styles.discoveryHints}>Try searching for a school name, or enter a postcode to find schools near you.</p>
|
||||||
<div className={styles.quickSearches}>
|
<div className={styles.quickSearches}>
|
||||||
<span className={styles.quickSearchLabel}>Quick searches:</span>
|
<span className={styles.quickSearchLabel}>Quick searches:</span>
|
||||||
@@ -75,46 +157,6 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
{initialSchools.schools.length > 0 && (
|
|
||||||
<div className={styles.viewToggle}>
|
|
||||||
<button
|
|
||||||
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
|
|
||||||
onClick={() => setResultsView('list')}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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
|
|
||||||
className={`${styles.viewToggleBtn} ${resultsView === 'map' ? styles.active : ''}`}
|
|
||||||
onClick={() => setResultsView('map')}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results Section */}
|
{/* Results Section */}
|
||||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||||
@@ -126,19 +168,67 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSearch && resultsView === 'list' && (
|
{hasSearch && (
|
||||||
<div className={styles.resultsHeader}>
|
<div className={styles.resultsHeader}>
|
||||||
<h2 aria-live="polite" aria-atomic="true">
|
<h2 aria-live="polite" aria-atomic="true">
|
||||||
{initialSchools.total.toLocaleString()} school
|
{isLocationSearch && initialSchools.location_info
|
||||||
{initialSchools.total !== 1 ? 's' : ''} found
|
? `${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>
|
</h2>
|
||||||
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
|
<div className={styles.resultsHeaderActions}>
|
||||||
<option value="default">Sort: Relevance</option>
|
{isLocationSearch && initialSchools.schools.length > 0 && (
|
||||||
<option value="rwm_desc">Highest R, W & M %</option>
|
<div className={styles.viewToggle}>
|
||||||
<option value="rwm_asc">Lowest R, W & M %</option>
|
<button
|
||||||
{isLocationSearch && <option value="distance">Nearest first</option>}
|
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
|
||||||
<option value="name_asc">Name A–Z</option>
|
onClick={() => setResultsView('list')}
|
||||||
</select>
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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
|
||||||
|
className={`${styles.viewToggleBtn} ${resultsView === 'map' ? styles.active : ''}`}
|
||||||
|
onClick={() => setResultsView('map')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
{(!isSecondaryView || isMixedView) && <option value="rwm_desc">Highest Reading, Writing & Maths %</option>}
|
||||||
|
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest Reading, Writing & Maths %</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -147,7 +237,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('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('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('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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -167,13 +256,16 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
<div className={styles.mapViewContainer}>
|
<div className={styles.mapViewContainer}>
|
||||||
<div className={styles.mapContainer}>
|
<div className={styles.mapContainer}>
|
||||||
<SchoolMap
|
<SchoolMap
|
||||||
schools={initialSchools.schools}
|
schools={isLoadingMap ? initialSchools.schools : mapSchools}
|
||||||
center={initialSchools.location_info?.coordinates}
|
center={initialSchools.location_info?.coordinates}
|
||||||
|
referencePoint={initialSchools.location_info?.coordinates}
|
||||||
onMarkerClick={setSelectedMapSchool}
|
onMarkerClick={setSelectedMapSchool}
|
||||||
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
|
laAverages={laAverages}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.compactList}>
|
<div className={styles.compactList}>
|
||||||
{initialSchools.schools.map((school) => (
|
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
|
||||||
<div
|
<div
|
||||||
key={school.urn}
|
key={school.urn}
|
||||||
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
||||||
@@ -182,6 +274,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
school={school}
|
school={school}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchool}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -196,6 +289,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
school={selectedMapSchool}
|
school={selectedMapSchool}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchool}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
||||||
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,23 +300,45 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
<>
|
<>
|
||||||
<div className={styles.schoolList}>
|
<div className={styles.schoolList}>
|
||||||
{sortedSchools.map((school) => (
|
{sortedSchools.map((school) => (
|
||||||
<SchoolRow
|
school.attainment_8_score != null ? (
|
||||||
key={school.urn}
|
<SecondarySchoolRow
|
||||||
school={school}
|
key={school.urn}
|
||||||
isLocationSearch={isLocationSearch}
|
school={school}
|
||||||
onAddToCompare={addSchool}
|
isLocationSearch={isLocationSearch}
|
||||||
onRemoveFromCompare={removeSchool}
|
onAddToCompare={addSchool}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
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}
|
||||||
|
isLocationSearch={isLocationSearch}
|
||||||
|
onAddToCompare={addSchool}
|
||||||
|
onRemoveFromCompare={removeSchool}
|
||||||
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
|
/>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{initialSchools.total_pages > 1 && (
|
{(hasMore || allSchools.length < initialSchools.total) && (
|
||||||
<Pagination
|
<div className={styles.loadMoreSection}>
|
||||||
currentPage={initialSchools.page}
|
<p className={styles.loadMoreCount}>
|
||||||
totalPages={initialSchools.total_pages}
|
Showing {allSchools.length.toLocaleString()} of {initialSchools.total.toLocaleString()} schools
|
||||||
total={initialSchools.total}
|
</p>
|
||||||
/>
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
className={`btn btn-secondary ${styles.loadMoreButton}`}
|
||||||
|
>
|
||||||
|
{isLoadingMore ? 'Loading...' : 'Load more schools'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -236,14 +352,33 @@ interface CompactSchoolItemProps {
|
|||||||
school: School;
|
school: School;
|
||||||
onAddToCompare: (school: School) => void;
|
onAddToCompare: (school: School) => void;
|
||||||
isInCompare: boolean;
|
isInCompare: boolean;
|
||||||
|
nationalAvgRwm?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoolItemProps) {
|
function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm }: CompactSchoolItemProps) {
|
||||||
|
const ofstedBadge = buildOfstedListBadge(school);
|
||||||
|
const isSecondary = school.attainment_8_score != null;
|
||||||
|
|
||||||
|
// vs-national delta for primary schools
|
||||||
|
const rwmDelta =
|
||||||
|
!isSecondary && school.rwm_expected_pct != null && nationalAvgRwm != null
|
||||||
|
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const deltaStyle: React.CSSProperties =
|
||||||
|
rwmDelta == null
|
||||||
|
? {}
|
||||||
|
: rwmDelta >= 2
|
||||||
|
? { fontSize: '0.7rem', color: 'var(--accent-teal, #2d7d7d)', fontWeight: 600 }
|
||||||
|
: rwmDelta <= -2
|
||||||
|
? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 }
|
||||||
|
: { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.compactItem}>
|
<div className={styles.compactItem}>
|
||||||
<div className={styles.compactItemContent}>
|
<div className={styles.compactItemContent}>
|
||||||
<div className={styles.compactItemHeader}>
|
<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}
|
{school.school_name}
|
||||||
</a>
|
</a>
|
||||||
{school.distance !== undefined && school.distance !== null && (
|
{school.distance !== undefined && school.distance !== null && (
|
||||||
@@ -252,17 +387,48 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.compactItemMeta}>
|
{/* Ofsted badge */}
|
||||||
{school.school_type && <span>{school.school_type}</span>}
|
<div style={{ marginBottom: '0.25rem' }}>
|
||||||
{school.local_authority && <span>{school.local_authority}</span>}
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.0625rem 0.375rem',
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: '3px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
|
||||||
|
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#3c8c3c' } :
|
||||||
|
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: '#b8920e' } :
|
||||||
|
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
|
||||||
|
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
|
||||||
|
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
|
||||||
|
{ background: '#e0e0e0', color: '#666' }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ofstedBadge.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Headline metric + delta */}
|
||||||
<div className={styles.compactItemStats}>
|
<div className={styles.compactItemStats}>
|
||||||
<span className={styles.compactStat}>
|
<span className={styles.compactStat}>
|
||||||
<strong>{school.rwm_expected_pct !== null ? `${school.rwm_expected_pct}%` : '-'}</strong> RWM
|
<strong>
|
||||||
</span>
|
{isSecondary
|
||||||
<span className={styles.compactStat}>
|
? (school.attainment_8_score != null ? school.attainment_8_score.toFixed(1) : '-')
|
||||||
<strong>{school.total_pupils || '-'}</strong> pupils
|
: (school.rwm_expected_pct != null ? `${school.rwm_expected_pct}%` : '-')}
|
||||||
|
</strong>
|
||||||
|
{' '}
|
||||||
|
{isSecondary ? 'Att 8' : 'RWM'}
|
||||||
</span>
|
</span>
|
||||||
|
{rwmDelta != null && (
|
||||||
|
<span style={deltaStyle}>
|
||||||
|
{rwmDelta >= 2
|
||||||
|
? `+${rwmDelta} pts vs national`
|
||||||
|
: rwmDelta <= -2
|
||||||
|
? `${rwmDelta} pts vs national`
|
||||||
|
: '≈ national avg'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.compactItemActions}>
|
<div className={styles.compactItemActions}>
|
||||||
@@ -272,7 +438,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
|||||||
>
|
>
|
||||||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||||||
</button>
|
</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
|
View
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
|
import { schoolUrl } from '@/lib/utils';
|
||||||
|
|
||||||
// Fix for default marker icons in Next.js
|
// Fix for default marker icons in Next.js
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
@@ -22,12 +23,48 @@ interface LeafletMapInnerProps {
|
|||||||
schools: School[];
|
schools: School[];
|
||||||
center: [number, number];
|
center: [number, number];
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
referencePoint?: [number, number];
|
||||||
onMarkerClick?: (school: School) => void;
|
onMarkerClick?: (school: School) => void;
|
||||||
|
nationalAvgRwm?: number | null;
|
||||||
|
laAverages?: Record<string, number | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }: LeafletMapInnerProps) {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Popup helpers (must work in plain JS string templates — no React / CSS Modules)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopupBadge {
|
||||||
|
label: string;
|
||||||
|
style: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPopupBadge(school: School): PopupBadge {
|
||||||
|
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
||||||
|
const yearStr = year ? ` · ${year}` : '';
|
||||||
|
if (school.ofsted_grade) {
|
||||||
|
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
||||||
|
const colours: Record<number, string> = {
|
||||||
|
1: 'background:#d4f0ea;color:#2d7d7d',
|
||||||
|
2: 'background:rgba(60,140,60,0.12);color:#3c8c3c',
|
||||||
|
3: 'background:#fef3cd;color:#b8920e',
|
||||||
|
4: 'background:#fde8e0;color:#e07256',
|
||||||
|
};
|
||||||
|
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||||||
|
}
|
||||||
|
if (school.ofsted_framework === 'ReportCard') {
|
||||||
|
return { label: `Report Card${yearStr}`, style: 'background:#5a3a6e;color:#fff' };
|
||||||
|
}
|
||||||
|
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const refMarkerRef = useRef<L.Marker | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapContainerRef.current) return;
|
if (!mapContainerRef.current) return;
|
||||||
@@ -42,27 +79,109 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
|
|||||||
}).addTo(mapRef.current);
|
}).addTo(mapRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing markers
|
// Clear existing school markers (not the reference pin)
|
||||||
mapRef.current.eachLayer((layer) => {
|
mapRef.current.eachLayer((layer) => {
|
||||||
if (layer instanceof L.Marker) {
|
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
|
||||||
mapRef.current!.removeLayer(layer);
|
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
|
// Add markers for schools
|
||||||
schools.forEach((school) => {
|
schools.forEach((school) => {
|
||||||
if (school.latitude && school.longitude && mapRef.current) {
|
if (school.latitude && school.longitude && mapRef.current) {
|
||||||
const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current);
|
const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current);
|
||||||
|
|
||||||
// Create popup content
|
// Create popup content
|
||||||
const popupContent = `
|
const badge = buildPopupBadge(school);
|
||||||
<div style="min-width: 200px;">
|
const isSecondary = school.attainment_8_score != null;
|
||||||
<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>` : ''}
|
// Phase label
|
||||||
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">${school.school_type}</div>` : ''}
|
const rawPhase = (school.phase ?? '').toLowerCase();
|
||||||
<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>
|
const phaseLabel =
|
||||||
</div>
|
rawPhase.includes('secondary') ? 'Secondary' :
|
||||||
`;
|
rawPhase === 'all-through' ? 'All-through' :
|
||||||
|
rawPhase.includes('primary') ? 'Primary' :
|
||||||
|
isSecondary ? 'Secondary' : 'Primary';
|
||||||
|
|
||||||
|
// Distance string
|
||||||
|
const distanceStr =
|
||||||
|
school.distance != null ? ` · ${school.distance.toFixed(1)} mi` : '';
|
||||||
|
|
||||||
|
// Headline metric
|
||||||
|
let metricHtml = '';
|
||||||
|
if (isSecondary) {
|
||||||
|
const score = school.attainment_8_score!;
|
||||||
|
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
|
||||||
|
let deltaLine = '';
|
||||||
|
if (laAvg != null) {
|
||||||
|
const diff = Math.round((score - laAvg) * 10) / 10;
|
||||||
|
const sign = diff >= 0 ? '+' : '';
|
||||||
|
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
||||||
|
// vs primary RWM % where ±2 pts is the threshold
|
||||||
|
const colour = diff >= 0.5 ? '#2d7d7d' : diff <= -0.5 ? '#e07256' : '#8a847a';
|
||||||
|
const laName = escapeHtml(school.local_authority ?? 'LA');
|
||||||
|
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
||||||
|
}
|
||||||
|
metricHtml = `<div style="margin-bottom:4px">
|
||||||
|
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||||||
|
<span style="font-size:11px;color:#8a847a;margin-left:4px">Attainment 8</span>
|
||||||
|
${deltaLine}
|
||||||
|
</div>`;
|
||||||
|
} else if (school.rwm_expected_pct != null) {
|
||||||
|
const rwm = school.rwm_expected_pct;
|
||||||
|
let deltaLine = '';
|
||||||
|
if (nationalAvgRwm != null) {
|
||||||
|
const diff = Math.round(rwm - nationalAvgRwm);
|
||||||
|
const colour = diff >= 2 ? '#2d7d7d' : diff <= -2 ? '#e07256' : '#8a847a';
|
||||||
|
const text =
|
||||||
|
diff >= 2 ? `+${diff} pts vs national` :
|
||||||
|
diff <= -2 ? `${diff} pts vs national` :
|
||||||
|
'≈ national avg';
|
||||||
|
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
|
||||||
|
}
|
||||||
|
metricHtml = `<div style="margin-bottom:4px">
|
||||||
|
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${rwm}%</span>
|
||||||
|
<span style="font-size:11px;color:#8a847a;margin-left:4px">Reading, Writing & Maths</span>
|
||||||
|
${deltaLine}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = schoolUrl(school.urn, school.school_name);
|
||||||
|
|
||||||
|
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
|
||||||
|
<strong style="font-size:13px;color:#1a1612;line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
||||||
|
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#8a847a;margin-bottom:8px">
|
||||||
|
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
||||||
|
</div>
|
||||||
|
${metricHtml}
|
||||||
|
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:#2d7d7d;color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
marker.bindPopup(popupContent);
|
marker.bindPopup(popupContent);
|
||||||
|
|
||||||
@@ -88,7 +207,7 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
|
|||||||
return () => {
|
return () => {
|
||||||
// Don't destroy map on every update, just clean markers
|
// Don't destroy map on every update, just clean markers
|
||||||
};
|
};
|
||||||
}, [schools, center, zoom, onMarkerClick]);
|
}, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]);
|
||||||
|
|
||||||
// Cleanup map on unmount
|
// Cleanup map on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,34 @@
|
|||||||
|
.chartOuter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trendSummary {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.chartWrapper {
|
.chartWrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.covidNote,
|
||||||
|
.chartHint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.chartWrapper {
|
.chartWrapper {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@@ -16,190 +16,285 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
ChartOptions,
|
ChartOptions,
|
||||||
|
ChartDataset,
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import type { SchoolResult } from '@/lib/types';
|
import type { SchoolResult } from '@/lib/types';
|
||||||
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
import styles from './PerformanceChart.module.css';
|
import styles from './PerformanceChart.module.css';
|
||||||
|
|
||||||
// Register Chart.js components
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
||||||
ChartJS.register(
|
|
||||||
CategoryScale,
|
interface NationalByYear {
|
||||||
LinearScale,
|
year: number;
|
||||||
PointElement,
|
primary: Record<string, number>;
|
||||||
LineElement,
|
secondary: Record<string, number>;
|
||||||
Title,
|
}
|
||||||
Tooltip,
|
|
||||||
Legend
|
|
||||||
);
|
|
||||||
|
|
||||||
interface PerformanceChartProps {
|
interface PerformanceChartProps {
|
||||||
data: SchoolResult[];
|
data: SchoolResult[];
|
||||||
schoolName: string;
|
schoolName: string;
|
||||||
|
isSecondary?: boolean;
|
||||||
|
/** National average RWM expected % for the latest year — fallback if no by_year data */
|
||||||
|
nationalRwmAvg?: number | null;
|
||||||
|
/** National average Attainment 8 for the latest year — fallback if no by_year data */
|
||||||
|
nationalAtt8Avg?: number | null;
|
||||||
|
/** Per-year national averages — used to draw a changing reference line */
|
||||||
|
nationalByYear?: NationalByYear[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
|
// Academic years when SATs/GCSEs were cancelled due to COVID
|
||||||
// Sort data by year
|
const COVID_YEARS = new Set([201920, 202021]);
|
||||||
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
|
||||||
const years = sortedData.map(d => d.year.toString());
|
|
||||||
|
|
||||||
// Prepare datasets
|
export function PerformanceChart({
|
||||||
const datasets = [
|
data,
|
||||||
|
isSecondary = false,
|
||||||
|
nationalRwmAvg,
|
||||||
|
nationalAtt8Avg,
|
||||||
|
nationalByYear,
|
||||||
|
}: PerformanceChartProps) {
|
||||||
|
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
||||||
|
const years = sortedData.map(d => formatAcademicYear(d.year));
|
||||||
|
|
||||||
|
// Build per-year national average series aligned to the school's data years.
|
||||||
|
// Falls back to a flat line using the scalar prop if by_year isn't available.
|
||||||
|
const natRefRwm: (number | null)[] = sortedData.map(d => {
|
||||||
|
if (nationalByYear) {
|
||||||
|
const match = nationalByYear.find(n => n.year === d.year);
|
||||||
|
return match?.primary?.rwm_expected_pct ?? null;
|
||||||
|
}
|
||||||
|
return nationalRwmAvg ?? null;
|
||||||
|
});
|
||||||
|
const natRefAtt8: (number | null)[] = sortedData.map(d => {
|
||||||
|
if (nationalByYear) {
|
||||||
|
const match = nationalByYear.find(n => n.year === d.year);
|
||||||
|
return match?.secondary?.attainment_8_score ?? null;
|
||||||
|
}
|
||||||
|
return nationalAtt8Avg ?? null;
|
||||||
|
});
|
||||||
|
const hasNatRwm = natRefRwm.some(v => v != null);
|
||||||
|
const hasNatAtt8 = natRefAtt8.some(v => v != null);
|
||||||
|
|
||||||
|
// ── Trend summary (primary only) ──────────────────────────────────────
|
||||||
|
const trendSummary = (() => {
|
||||||
|
if (isSecondary) return null;
|
||||||
|
const rwm = sortedData.filter(d => d.rwm_expected_pct != null);
|
||||||
|
if (rwm.length < 2) return null;
|
||||||
|
const latest = rwm[rwm.length - 1];
|
||||||
|
const prev = rwm[rwm.length - 2];
|
||||||
|
const best = rwm.reduce((a, b) => (b.rwm_expected_pct! > a.rwm_expected_pct! ? b : a));
|
||||||
|
const latestPct = Math.round(latest.rwm_expected_pct!);
|
||||||
|
const bestPct = Math.round(best.rwm_expected_pct!);
|
||||||
|
const delta = latest.rwm_expected_pct! - prev.rwm_expected_pct!;
|
||||||
|
const arrow = delta > 1 ? '↑' : delta < -1 ? '↓' : '→';
|
||||||
|
if (best.year === latest.year) {
|
||||||
|
return `${arrow} Best year on record — ${latestPct}% Reading, Writing & Maths`;
|
||||||
|
}
|
||||||
|
return `${arrow} Peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── COVID gap note ─────────────────────────────────────────────────────
|
||||||
|
const hasCovidGap = isSecondary
|
||||||
|
? false
|
||||||
|
: COVID_YEARS.size > 0 &&
|
||||||
|
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
|
||||||
|
|
||||||
|
// ── Datasets ──────────────────────────────────────────────────────────
|
||||||
|
const refLineStyle = {
|
||||||
|
borderColor: 'rgba(90,80,70,0.35)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderDash: [6, 4] as number[],
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0,
|
||||||
|
order: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const datasets: ChartDataset<'line'>[] = isSecondary ? [
|
||||||
{
|
{
|
||||||
label: 'RWM Expected %',
|
label: 'Attainment 8',
|
||||||
|
data: sortedData.map(d => d.attainment_8_score),
|
||||||
|
borderColor: '#2d7d7d',
|
||||||
|
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
yAxisID: 'y',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'English & Maths Grade 4+',
|
||||||
|
data: sortedData.map(d => d.english_maths_standard_pass_pct),
|
||||||
|
borderColor: '#c9a227',
|
||||||
|
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
yAxisID: 'y',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Progress 8',
|
||||||
|
data: sortedData.map(d => d.progress_8_score),
|
||||||
|
borderColor: 'rgb(139,92,246)',
|
||||||
|
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
hidden: true,
|
||||||
|
yAxisID: 'y1',
|
||||||
|
},
|
||||||
|
...(hasNatAtt8 ? [{
|
||||||
|
...refLineStyle,
|
||||||
|
label: 'National average',
|
||||||
|
data: natRefAtt8,
|
||||||
|
yAxisID: 'y',
|
||||||
|
} as ChartDataset<'line'>] : []),
|
||||||
|
] : [
|
||||||
|
{
|
||||||
|
label: 'Reading, Writing & Maths expected %',
|
||||||
data: sortedData.map(d => d.rwm_expected_pct),
|
data: sortedData.map(d => d.rwm_expected_pct),
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
borderColor: '#2d7d7d',
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||||
|
borderWidth: 2.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
yAxisID: 'y',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'RWM Higher %',
|
label: 'Exceeding expected level',
|
||||||
data: sortedData.map(d => d.rwm_high_pct),
|
data: sortedData.map(d => d.rwm_high_pct),
|
||||||
borderColor: 'rgb(16, 185, 129)',
|
borderColor: '#c9a227',
|
||||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||||
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
yAxisID: 'y',
|
||||||
},
|
},
|
||||||
|
...(hasNatRwm ? [{
|
||||||
|
...refLineStyle,
|
||||||
|
label: 'National average',
|
||||||
|
data: natRefRwm,
|
||||||
|
yAxisID: 'y',
|
||||||
|
} as ChartDataset<'line'>] : []),
|
||||||
{
|
{
|
||||||
label: 'Reading Progress',
|
label: 'Reading progress',
|
||||||
data: sortedData.map(d => d.reading_progress),
|
data: sortedData.map(d => d.reading_progress),
|
||||||
borderColor: 'rgb(245, 158, 11)',
|
borderColor: 'rgb(59,130,246)',
|
||||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||||
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
hidden: true,
|
||||||
yAxisID: 'y1',
|
yAxisID: 'y1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Writing Progress',
|
label: 'Writing progress',
|
||||||
data: sortedData.map(d => d.writing_progress),
|
data: sortedData.map(d => d.writing_progress),
|
||||||
borderColor: 'rgb(139, 92, 246)',
|
borderColor: 'rgb(139,92,246)',
|
||||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||||
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
hidden: true,
|
||||||
yAxisID: 'y1',
|
yAxisID: 'y1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Maths Progress',
|
label: 'Maths progress',
|
||||||
data: sortedData.map(d => d.maths_progress),
|
data: sortedData.map(d => d.maths_progress),
|
||||||
borderColor: 'rgb(236, 72, 153)',
|
borderColor: 'rgb(236,72,153)',
|
||||||
backgroundColor: 'rgba(236, 72, 153, 0.1)',
|
backgroundColor: 'rgba(236,72,153,0.08)',
|
||||||
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
hidden: true,
|
||||||
yAxisID: 'y1',
|
yAxisID: 'y1',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const chartData = {
|
|
||||||
labels: years,
|
|
||||||
datasets,
|
|
||||||
};
|
|
||||||
|
|
||||||
const options: ChartOptions<'line'> = {
|
const options: ChartOptions<'line'> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: {
|
interaction: { mode: 'index', intersect: false },
|
||||||
mode: 'index' as const,
|
|
||||||
intersect: false,
|
|
||||||
},
|
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'top' as const,
|
position: 'top',
|
||||||
labels: {
|
labels: {
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
padding: 15,
|
padding: 14,
|
||||||
font: {
|
font: { size: 12 },
|
||||||
size: 12,
|
filter: item => item.text !== 'National average' || true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||||
|
padding: 12,
|
||||||
|
titleFont: { size: 13 },
|
||||||
|
bodyFont: { size: 12 },
|
||||||
|
callbacks: {
|
||||||
|
label: ctx => {
|
||||||
|
const label = ctx.dataset.label ?? '';
|
||||||
|
if (ctx.parsed.y == null) return label;
|
||||||
|
const isProgress = ctx.dataset.yAxisID === 'y1';
|
||||||
|
const suffix = isProgress ? '' : '%';
|
||||||
|
const val = ctx.parsed.y.toFixed(1);
|
||||||
|
return `${label}: ${val}${suffix}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: `${schoolName} - Performance Over Time`,
|
|
||||||
font: {
|
|
||||||
size: 16,
|
|
||||||
weight: 'bold',
|
|
||||||
},
|
|
||||||
padding: {
|
|
||||||
bottom: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
padding: 12,
|
|
||||||
titleFont: {
|
|
||||||
size: 14,
|
|
||||||
},
|
|
||||||
bodyFont: {
|
|
||||||
size: 13,
|
|
||||||
},
|
|
||||||
callbacks: {
|
|
||||||
label: function(context) {
|
|
||||||
let label = context.dataset.label || '';
|
|
||||||
if (label) {
|
|
||||||
label += ': ';
|
|
||||||
}
|
|
||||||
if (context.parsed.y !== null) {
|
|
||||||
if (context.dataset.yAxisID === 'y1') {
|
|
||||||
// Progress scores
|
|
||||||
label += context.parsed.y.toFixed(1);
|
|
||||||
} else {
|
|
||||||
// Percentages
|
|
||||||
label += context.parsed.y.toFixed(1) + '%';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
type: 'linear' as const,
|
type: 'linear',
|
||||||
display: true,
|
display: true,
|
||||||
position: 'left' as const,
|
position: 'left',
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Percentage (%)',
|
text: isSecondary ? 'Score / %' : 'Percentage (%)',
|
||||||
font: {
|
font: { size: 11 },
|
||||||
size: 12,
|
|
||||||
weight: 'bold',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: isSecondary ? undefined : 100,
|
||||||
grid: {
|
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||||
color: 'rgba(0, 0, 0, 0.05)',
|
ticks: { font: { size: 11 } },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
y1: {
|
y1: {
|
||||||
type: 'linear' as const,
|
type: 'linear',
|
||||||
display: true,
|
display: true,
|
||||||
position: 'right' as const,
|
position: 'right',
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Progress Score',
|
text: isSecondary ? 'Progress 8' : 'Progress score',
|
||||||
font: {
|
font: { size: 11 },
|
||||||
size: 12,
|
|
||||||
weight: 'bold',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
drawOnChartArea: false,
|
|
||||||
},
|
},
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
ticks: { font: { size: 11 } },
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
grid: {
|
grid: { display: false },
|
||||||
display: false,
|
ticks: { font: { size: 11 } },
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: 'Year',
|
|
||||||
font: {
|
|
||||||
size: 12,
|
|
||||||
weight: 'bold',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.chartWrapper}>
|
<div className={styles.chartOuter}>
|
||||||
<Line data={chartData} options={options} />
|
{trendSummary && (
|
||||||
|
<div className={styles.trendSummary}>{trendSummary}</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.chartWrapper}>
|
||||||
|
<Line data={{ labels: years, datasets }} options={options} />
|
||||||
|
</div>
|
||||||
|
{hasCovidGap && (
|
||||||
|
<p className={styles.covidNote}>
|
||||||
|
* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isSecondary && (
|
||||||
|
<p className={styles.chartHint}>
|
||||||
|
Progress scores (Reading, Writing, Maths) are hidden by default — click them in the legend to show.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
@@ -22,6 +23,47 @@
|
|||||||
line-height: 1.6;
|
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 */
|
||||||
.filters {
|
.filters {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
@@ -52,6 +94,8 @@
|
|||||||
|
|
||||||
.filterSelect {
|
.filterSelect {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem 1rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
@@ -96,6 +140,8 @@
|
|||||||
|
|
||||||
.tableWrapper {
|
.tableWrapper {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankingsTable {
|
.rankingsTable {
|
||||||
@@ -304,7 +350,8 @@
|
|||||||
.filterGroup {
|
.filterGroup {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
min-width: 100%;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankingsSection {
|
.rankingsSection {
|
||||||
@@ -327,13 +374,30 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schoolHeader {
|
/* Hide less-critical columns on mobile so the metric value stays visible */
|
||||||
min-width: 180px;
|
.typeHeader,
|
||||||
|
.typeCell,
|
||||||
|
.actionHeader,
|
||||||
|
.actionCell {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.areaHeader,
|
.schoolHeader {
|
||||||
.typeHeader {
|
min-width: 140px;
|
||||||
min-width: 100px;
|
}
|
||||||
|
|
||||||
|
.areaHeader {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueHeader,
|
||||||
|
.valueCell {
|
||||||
|
width: auto;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankHeader {
|
||||||
|
width: 40px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* RankingsView Component
|
* RankingsView Component
|
||||||
* Client-side rankings interface with filters
|
* Client-side rankings interface with phase tabs and filters
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
@@ -8,10 +8,29 @@
|
|||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
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 { EmptyState } from './EmptyState';
|
||||||
import styles from './RankingsView.module.css';
|
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 {
|
interface RankingsViewProps {
|
||||||
rankings: RankingEntry[];
|
rankings: RankingEntry[];
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
@@ -19,6 +38,7 @@ interface RankingsViewProps {
|
|||||||
selectedMetric: string;
|
selectedMetric: string;
|
||||||
selectedArea?: string;
|
selectedArea?: string;
|
||||||
selectedYear?: number;
|
selectedYear?: number;
|
||||||
|
selectedPhase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RankingsView({
|
export function RankingsView({
|
||||||
@@ -28,12 +48,17 @@ export function RankingsView({
|
|||||||
selectedMetric,
|
selectedMetric,
|
||||||
selectedArea,
|
selectedArea,
|
||||||
selectedYear,
|
selectedYear,
|
||||||
|
selectedPhase = 'primary',
|
||||||
}: RankingsViewProps) {
|
}: RankingsViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { addSchool, isSelected } = useComparison();
|
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 updateFilters = (updates: Record<string, string | undefined>) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
@@ -48,6 +73,11 @@ export function RankingsView({
|
|||||||
router.push(`${pathname}?${params.toString()}`);
|
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) => {
|
const handleMetricChange = (metric: string) => {
|
||||||
updateFilters({ metric });
|
updateFilters({ metric });
|
||||||
};
|
};
|
||||||
@@ -63,7 +93,6 @@ export function RankingsView({
|
|||||||
const handleAddToCompare = (ranking: RankingEntry) => {
|
const handleAddToCompare = (ranking: RankingEntry) => {
|
||||||
addSchool({
|
addSchool({
|
||||||
...ranking,
|
...ranking,
|
||||||
// Ensure required School fields are present
|
|
||||||
address: null,
|
address: null,
|
||||||
postcode: null,
|
postcode: null,
|
||||||
latitude: null,
|
latitude: null,
|
||||||
@@ -77,6 +106,9 @@ export function RankingsView({
|
|||||||
const isProgressScore = selectedMetric.includes('progress');
|
const isProgressScore = selectedMetric.includes('progress');
|
||||||
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate');
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -84,10 +116,26 @@ export function RankingsView({
|
|||||||
<h1>School Rankings</h1>
|
<h1>School Rankings</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Top-performing schools by {metricLabel.toLowerCase()}
|
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>
|
</p>
|
||||||
</header>
|
</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 && (
|
{currentMetricDef?.description && (
|
||||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||||
)}
|
)}
|
||||||
@@ -107,46 +155,17 @@ export function RankingsView({
|
|||||||
onChange={(e) => handleMetricChange(e.target.value)}
|
onChange={(e) => handleMetricChange(e.target.value)}
|
||||||
className={styles.filterSelect}
|
className={styles.filterSelect}
|
||||||
>
|
>
|
||||||
<optgroup label="Expected Standard">
|
{optgroups.map(({ label, category }) => {
|
||||||
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
if (groupMetrics.length === 0) return null;
|
||||||
))}
|
return (
|
||||||
</optgroup>
|
<optgroup key={category} label={label}>
|
||||||
<optgroup label="Higher Standard">
|
{groupMetrics.map((metric) => (
|
||||||
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
))}
|
||||||
))}
|
</optgroup>
|
||||||
</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) => (
|
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,11 +199,11 @@ export function RankingsView({
|
|||||||
className={styles.filterSelect}
|
className={styles.filterSelect}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{filters.years.length > 0 ? `${Math.max(...filters.years)} (Latest)` : 'Latest'}
|
{filters.years.length > 0 ? `${formatAcademicYear(Math.max(...filters.years))} (Latest)` : 'Latest'}
|
||||||
</option>
|
</option>
|
||||||
{filters.years.map((year) => (
|
{filters.years.map((year) => (
|
||||||
<option key={year} value={year}>
|
<option key={year} value={year}>
|
||||||
{year}
|
{formatAcademicYear(year)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -199,7 +218,7 @@ export function RankingsView({
|
|||||||
message="Try selecting a different metric, area, or year."
|
message="Try selecting a different metric, area, or year."
|
||||||
action={{
|
action={{
|
||||||
label: 'Clear filters',
|
label: 'Clear filters',
|
||||||
onClick: () => router.push(pathname),
|
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -248,7 +267,7 @@ export function RankingsView({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.schoolCell}>
|
<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}
|
{ranking.school_name}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -258,7 +277,7 @@ export function RankingsView({
|
|||||||
<strong>{displayValue}</strong>
|
<strong>{displayValue}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.actionCell}>
|
<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
|
<button
|
||||||
onClick={() => handleAddToCompare(ranking)}
|
onClick={() => handleAddToCompare(ranking)}
|
||||||
disabled={alreadyInComparison}
|
disabled={alreadyInComparison}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/* SatsChart — Cascade bar chart for KS2 SATs results */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Individual subject column ── */
|
||||||
|
.subjectChart {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subjectName {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartArea {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gridlines ── */
|
||||||
|
.gridlines {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridline {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── National average marker ── */
|
||||||
|
.natLine {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: calc(100% - 20px);
|
||||||
|
width: 1.5px;
|
||||||
|
background: rgba(224, 114, 86, 0.25); /* --accent-coral at 25% */
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.natPill {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 3;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bar rows ── */
|
||||||
|
.barGroup {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barExpected {
|
||||||
|
background: var(--accent-teal-light, #3a9e9e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barExceeding {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barLabel {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barLabelSuffix {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ruler ── */
|
||||||
|
.ruler {
|
||||||
|
position: relative;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulerTick {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulerLabel {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anchor first/last labels to edges */
|
||||||
|
.rulerLabelFirst {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulerLabelLast {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legend ── */
|
||||||
|
.legend {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendSwatch {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user