feat: add secondary school support with KS4 data and metric tooltips
Some checks failed
Build and Push Docker Images / Build Frontend (Next.js) (push) Has been cancelled
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Has been cancelled
Build and Push Docker Images / Trigger Portainer Update (push) Has been cancelled
Build and Push Docker Images / Build Backend (FastAPI) (push) Has been cancelled

- Backend: replace INNER JOIN ks2 with UNION ALL (ks2 + ks4) so primary
  and secondary schools both appear in the main DataFrame
- Backend: add /api/national-averages endpoint computing means from live
  data, replacing the hardcoded NATIONAL_AVG constant on the frontend
- Backend: add phase filter param to /api/schools; return phases from
  /api/filters; fix hardcoded "phase": "Primary" in school detail endpoint
- Backend: add KS4 metric definitions (Attainment 8, Progress 8, EBacc,
  English & Maths pass rates) to METRIC_DEFINITIONS and RANKING_COLUMNS
- Frontend: SchoolDetailView is now phase-aware — secondary schools show
  a GCSE Results section (Att8, P8, E&M, EBacc) instead of SATs; phonics
  tab hidden for secondary; admissions says Year 7 instead of Year 3;
  history table shows KS4 columns; chart datasets switch for secondary
- Frontend: new MetricTooltip component (CSS-only ⓘ icon) backed by
  METRIC_EXPLANATIONS — added to RWM, GPS, SEN, EAL, IDACI, progress
  scores and all KS4 metrics throughout SchoolDetailView and SchoolCard
- Frontend: METRIC_EXPLANATIONS extended with KS4 terms (Attainment 8,
  Progress 8, EBacc) and previously missing terms (SEN, EHCP, EAL, IDACI)
- Frontend: SchoolCard expands "RWM" to "Reading, Writing & Maths" and
  shows Attainment 8 / English & Maths Grade 4+ for secondary schools
- Frontend: FilterBar adds Phase dropdown (Primary / Secondary / All-through)
- Frontend: HomeView hero copy updated; compact list shows phase-aware metric
- Global metadata updated to remove "primary only" framing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 14:59:40 +00:00
parent b0990e30ee
commit 5eff9af69c
16 changed files with 903 additions and 187 deletions

View File

@@ -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.
""" """
@@ -151,7 +151,7 @@ async def lifespan(app: FastAPI):
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
@@ -213,21 +213,23 @@ 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=50, 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(None, ge=1, le=100, description="Results per page"),
): ):
""" """
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()
@@ -253,9 +255,25 @@ async def get_schools(
) )
df_latest = df_latest.merge(prev_rwm, on="urn", how="left") df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
# Phase filter
if phase:
phase_lower = phase.lower()
if phase_lower in ("primary", "secondary", "all-through", "all_through"):
# Map param values to GIAS phase strings (partial match)
phase_map = {
"primary": "primary",
"secondary": "secondary",
"all-through": "all-through",
"all_through": "all-through",
}
phase_substr = phase_map[phase_lower]
schools_df_phase_mask = df_latest["phase"].str.lower().str.contains(phase_substr, na=False)
df_latest = df_latest[schools_df_phase_mask]
# 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",
@@ -264,6 +282,8 @@ async def get_schools(
"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
@@ -364,7 +384,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")
@@ -406,7 +426,7 @@ 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"),
@@ -433,7 +453,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:
@@ -487,10 +507,66 @@ async def get_filter_options(request: Request):
"years": [], "years": [],
} }
# Phases: return values from data, ordered sensibly
phases = sorted(df["phase"].dropna().unique().tolist()) if "phase" in df.columns else []
return { return {
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()), "local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
"school_types": sorted(df["school_type"].dropna().unique().tolist()), "school_types": sorted(df["school_type"].dropna().unique().tolist()),
"years": sorted(df["year"].dropna().unique().tolist()), "years": sorted(df["year"].dropna().unique().tolist()),
"phases": phases,
}
@app.get("/api/national-averages")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_national_averages(request: Request):
"""
Compute national average for each metric from the latest data year.
Returns separate averages for primary (KS2) and secondary (KS4) schools.
Values are derived from the loaded DataFrame so they automatically
stay current when new data is loaded.
"""
df = load_school_data()
if df.empty:
return {"primary": {}, "secondary": {}}
latest_year = int(df["year"].max())
df_latest = df[df["year"] == latest_year]
ks2_metrics = [
"rwm_expected_pct", "rwm_high_pct",
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
"reading_avg_score", "maths_avg_score", "gps_avg_score",
"reading_progress", "writing_progress", "maths_progress",
"overall_absence_pct", "persistent_absence_pct",
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct",
]
ks4_metrics = [
"attainment_8_score", "progress_8_score",
"english_maths_standard_pass_pct", "english_maths_strong_pass_pct",
"ebacc_entry_pct", "ebacc_standard_pass_pct", "ebacc_strong_pass_pct",
"ebacc_avg_score", "gcse_grade_91_pct",
]
def _means(sub_df, metric_list):
out = {}
for col in metric_list:
if col in sub_df.columns:
val = sub_df[col].dropna()
if len(val) > 0:
out[col] = round(float(val.mean()), 2)
return out
# Primary: schools where KS2 data is non-null
primary_df = df_latest[df_latest["rwm_expected_pct"].notna()]
# Secondary: schools where KS4 data is non-null
secondary_df = df_latest[df_latest["attainment_8_score"].notna()]
return {
"year": latest_year,
"primary": _means(primary_df, ks2_metrics),
"secondary": _means(secondary_df, ks4_metrics),
} }
@@ -498,7 +574,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.
@@ -517,7 +593,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
), ),
@@ -526,7 +602,7 @@ async def get_rankings(
None, description="Filter by local authority", max_length=100 None, description="Filter by local authority", max_length=100
), ),
): ):
"""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)

View File

@@ -113,6 +113,7 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl
# ============================================================================= # =============================================================================
_MAIN_QUERY = text(""" _MAIN_QUERY = text("""
-- Branch 1: Primary schools (KS2 data; KS4 columns NULL)
SELECT SELECT
s.urn, s.urn,
s.school_name, s.school_name,
@@ -137,11 +138,11 @@ _MAIN_QUERY = text("""
l.postcode, l.postcode,
l.latitude, l.latitude,
l.longitude, l.longitude,
-- KS2 performance
k.year, k.year,
k.source_urn, k.source_urn,
k.total_pupils, k.total_pupils,
k.eligible_pupils, k.eligible_pupils,
-- KS2 columns
k.rwm_expected_pct, k.rwm_expected_pct,
k.rwm_high_pct, k.rwm_high_pct,
k.reading_expected_pct, k.reading_expected_pct,
@@ -175,11 +176,116 @@ _MAIN_QUERY = text("""
k.eal_pct, k.eal_pct,
k.sen_support_pct, k.sen_support_pct,
k.sen_ehcp_pct, k.sen_ehcp_pct,
k.stability_pct k.stability_pct,
-- KS4 columns (NULL for primary)
NULL::numeric AS attainment_8_score,
NULL::numeric AS progress_8_score,
NULL::numeric AS progress_8_lower_ci,
NULL::numeric AS progress_8_upper_ci,
NULL::numeric AS progress_8_english,
NULL::numeric AS progress_8_maths,
NULL::numeric AS progress_8_ebacc,
NULL::numeric AS progress_8_open,
NULL::numeric AS english_maths_strong_pass_pct,
NULL::numeric AS english_maths_standard_pass_pct,
NULL::numeric AS ebacc_entry_pct,
NULL::numeric AS ebacc_strong_pass_pct,
NULL::numeric AS ebacc_standard_pass_pct,
NULL::numeric AS ebacc_avg_score,
NULL::numeric AS gcse_grade_91_pct,
NULL::numeric AS prior_attainment_avg
FROM marts.dim_school s FROM marts.dim_school s
JOIN marts.dim_location l ON s.urn = l.urn JOIN marts.dim_location l ON s.urn = l.urn
JOIN marts.fact_ks2_performance k ON s.urn = k.urn JOIN marts.fact_ks2_performance k ON s.urn = k.urn
ORDER BY s.school_name, k.year
UNION ALL
-- Branch 2: Secondary schools (KS4 data; KS2 columns NULL)
SELECT
s.urn,
s.school_name,
s.phase,
s.school_type,
s.academy_trust_name AS trust_name,
s.academy_trust_uid AS trust_uid,
s.religious_character AS religious_denomination,
s.gender,
s.age_range,
s.capacity,
s.headteacher_name,
s.website,
s.ofsted_grade,
s.ofsted_date,
s.ofsted_framework,
l.local_authority_name AS local_authority,
l.local_authority_code,
l.address_line1 AS address1,
l.address_line2 AS address2,
l.town,
l.postcode,
l.latitude,
l.longitude,
k4.year,
k4.source_urn,
k4.total_pupils,
k4.eligible_pupils,
-- KS2 columns (NULL for secondary)
NULL::numeric AS rwm_expected_pct,
NULL::numeric AS rwm_high_pct,
NULL::numeric AS reading_expected_pct,
NULL::numeric AS reading_high_pct,
NULL::numeric AS reading_avg_score,
NULL::numeric AS reading_progress,
NULL::numeric AS writing_expected_pct,
NULL::numeric AS writing_high_pct,
NULL::numeric AS writing_progress,
NULL::numeric AS maths_expected_pct,
NULL::numeric AS maths_high_pct,
NULL::numeric AS maths_avg_score,
NULL::numeric AS maths_progress,
NULL::numeric AS gps_expected_pct,
NULL::numeric AS gps_high_pct,
NULL::numeric AS gps_avg_score,
NULL::numeric AS science_expected_pct,
NULL::numeric AS reading_absence_pct,
NULL::numeric AS writing_absence_pct,
NULL::numeric AS maths_absence_pct,
NULL::numeric AS gps_absence_pct,
NULL::numeric AS science_absence_pct,
NULL::numeric AS rwm_expected_boys_pct,
NULL::numeric AS rwm_high_boys_pct,
NULL::numeric AS rwm_expected_girls_pct,
NULL::numeric AS rwm_high_girls_pct,
NULL::numeric AS rwm_expected_disadvantaged_pct,
NULL::numeric AS rwm_expected_non_disadvantaged_pct,
NULL::numeric AS disadvantaged_gap,
NULL::numeric AS disadvantaged_pct,
NULL::numeric AS eal_pct,
k4.sen_support_pct,
k4.sen_ehcp_pct,
NULL::numeric AS stability_pct,
-- KS4 columns
k4.attainment_8_score,
k4.progress_8_score,
k4.progress_8_lower_ci,
k4.progress_8_upper_ci,
k4.progress_8_english,
k4.progress_8_maths,
k4.progress_8_ebacc,
k4.progress_8_open,
k4.english_maths_strong_pass_pct,
k4.english_maths_standard_pass_pct,
k4.ebacc_entry_pct,
k4.ebacc_strong_pass_pct,
k4.ebacc_standard_pass_pct,
k4.ebacc_avg_score,
k4.gcse_grade_91_pct,
k4.prior_attainment_avg
FROM marts.dim_school s
JOIN marts.dim_location l ON s.urn = l.urn
JOIN marts.fact_ks4_performance k4 ON s.urn = k4.urn
ORDER BY school_name, year
""") """)

View File

@@ -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 91 %",
"short_name": "GCSE 91",
"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

View File

@@ -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',
}, },
}; };

View File

@@ -11,6 +11,7 @@ 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;
@@ -19,7 +20,7 @@ interface HomePageProps {
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,6 +39,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
params.search || params.search ||
params.local_authority || params.local_authority ||
params.school_type || params.school_type ||
params.phase ||
params.postcode params.postcode
); );
@@ -52,6 +54,7 @@ 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,
@@ -65,7 +68,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: [] }}
totalSchools={dataInfo?.total_schools ?? null} totalSchools={dataInfo?.total_schools ?? null}
/> />
); );
@@ -76,7 +79,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: [] }}
totalSchools={null} totalSchools={null}
/> />
); );

View File

@@ -17,8 +17,8 @@ interface RankingsPageProps {
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

View File

@@ -27,6 +27,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
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') || '';
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -86,7 +87,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
}); });
}; };
const hasActiveFilters = currentSearch || currentLA || currentType || currentPostcode; const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode;
return ( return (
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}> <div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
@@ -152,6 +153,22 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
))} ))}
</select> </select>
{filters.phases && filters.phases.length > 0 && (
<select
value={currentPhase}
onChange={(e) => handleFilterChange('phase', e.target.value)}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All Phases</option>
{filters.phases.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
)}
{hasActiveFilters && ( {hasActiveFilters && (
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}> <button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
Clear Filters Clear Filters

View File

@@ -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>

View File

@@ -51,8 +51,8 @@ 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}>Compare School Performance</h1>
<p className={styles.heroDescription}>Search and compare KS2 results for thousands of schools across England</p> <p className={styles.heroDescription}>Search and compare SATs and GCSE results for thousands of schools across England</p>
</div> </div>
)} )}
@@ -64,7 +64,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
{/* 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>
@@ -258,7 +258,14 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
</div> </div>
<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>
{school.attainment_8_score != null
? school.attainment_8_score.toFixed(1)
: school.rwm_expected_pct !== null
? `${school.rwm_expected_pct}%`
: '-'}
</strong>{' '}
{school.attainment_8_score != null ? 'Att 8' : 'RWM'}
</span> </span>
<span className={styles.compactStat}> <span className={styles.compactStat}>
<strong>{school.total_pupils || '-'}</strong> pupils <strong>{school.total_pupils || '-'}</strong> pupils

View File

@@ -0,0 +1,83 @@
.wrapper {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 0.3em;
}
.icon {
font-size: 0.85em;
color: var(--text-muted, #8a7a72);
cursor: help;
line-height: 1;
user-select: none;
transition: color 0.15s ease;
}
.wrapper:hover .icon {
color: var(--accent-coral, #e07256);
}
.tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 9999;
width: 220px;
background: var(--bg-primary, #faf7f2);
border: 1px solid var(--border-color, #e8ddd4);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
pointer-events: none;
transition: opacity 0.15s ease, visibility 0.15s ease;
}
/* Keep tooltip visible when hovering over it */
.wrapper:hover .tooltip {
visibility: visible;
opacity: 1;
}
/* Small arrow pointing down */
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--border-color, #e8ddd4);
}
.tooltipLabel {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-primary, #2c2420);
}
.tooltipPlain {
font-size: 0.75rem;
color: var(--text-secondary, #5a4a44);
line-height: 1.4;
}
.tooltipDetail {
font-size: 0.7rem;
color: var(--text-muted, #8a7a72);
line-height: 1.4;
margin-top: 0.1rem;
}
/* Flip tooltip below when near top of screen */
@media (max-width: 480px) {
.tooltip {
width: 180px;
}
}

View File

@@ -0,0 +1,31 @@
'use client';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
import styles from './MetricTooltip.module.css';
interface MetricTooltipProps {
metricKey?: string;
label?: string;
plain?: string;
detail?: string;
}
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
const tooltipLabel = label ?? explanation?.label;
const tooltipPlain = plain ?? explanation?.plain;
const tooltipDetail = detail ?? explanation?.detail;
if (!tooltipPlain) return null;
return (
<span className={styles.wrapper}>
<span className={styles.icon} aria-label={tooltipLabel ?? 'More information'} role="img"></span>
<span className={styles.tooltip} role="tooltip">
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
</span>
</span>
);
}

View File

@@ -34,24 +34,50 @@ ChartJS.register(
interface PerformanceChartProps { interface PerformanceChartProps {
data: SchoolResult[]; data: SchoolResult[];
schoolName: string; schoolName: string;
isSecondary?: boolean;
} }
export function PerformanceChart({ data, schoolName }: PerformanceChartProps) { export function PerformanceChart({ data, schoolName, isSecondary = false }: PerformanceChartProps) {
// Sort data by year // Sort data by year
const sortedData = [...data].sort((a, b) => a.year - b.year); const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => d.year.toString()); const years = sortedData.map(d => d.year.toString());
// Prepare datasets // Prepare datasets — phase-aware
const datasets = [ const datasets = isSecondary ? [
{ {
label: 'RWM Expected %', label: 'Attainment 8',
data: sortedData.map(d => d.attainment_8_score),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
yAxisID: 'y',
},
{
label: 'English & Maths Grade 4+',
data: sortedData.map(d => d.english_maths_standard_pass_pct),
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.3,
yAxisID: 'y',
},
{
label: 'Progress 8',
data: sortedData.map(d => d.progress_8_score),
borderColor: 'rgb(245, 158, 11)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
tension: 0.3,
yAxisID: 'y1',
},
] : [
{
label: 'Reading, Writing & Maths Expected %',
data: sortedData.map(d => d.rwm_expected_pct), data: sortedData.map(d => d.rwm_expected_pct),
borderColor: 'rgb(59, 130, 246)', borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)', backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3, tension: 0.3,
}, },
{ {
label: 'RWM Higher %', label: 'Reading, Writing & Maths Higher %',
data: sortedData.map(d => d.rwm_high_pct), data: sortedData.map(d => d.rwm_high_pct),
borderColor: 'rgb(16, 185, 129)', borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)', backgroundColor: 'rgba(16, 185, 129, 0.1)',
@@ -153,14 +179,14 @@ export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
position: 'left' as const, position: 'left' as const,
title: { title: {
display: true, display: true,
text: 'Percentage (%)', text: isSecondary ? 'Score / Percentage (%)' : 'Percentage (%)',
font: { font: {
size: 12, size: 12,
weight: 'bold', 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)',
}, },
@@ -171,7 +197,7 @@ export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
position: 'right' as const, position: 'right' as const,
title: { title: {
display: true, display: true,
text: 'Progress Score', text: isSecondary ? 'Progress 8 Score' : 'Progress Score',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',

View File

@@ -48,12 +48,35 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
)} )}
</div> </div>
{(school.rwm_expected_pct !== null || school.reading_progress !== null) && ( {(school.rwm_expected_pct != null || school.attainment_8_score != null || school.reading_progress !== null) && (
<div className={styles.metrics}> <div className={styles.metrics}>
{/* KS4 card metrics for secondary schools */}
{school.attainment_8_score != null && (
<div className={styles.metric}>
<span className={styles.metricLabel}>
Attainment 8
<span className={styles.metricHint}>avg grade across best 8 GCSEs</span>
</span>
<div className={styles.metricValue}>
<strong>{school.attainment_8_score.toFixed(1)}</strong>
</div>
</div>
)}
{school.english_maths_standard_pass_pct != null && (
<div className={styles.metric}>
<span className={styles.metricLabel}>
English &amp; Maths Grade 4+
<span className={styles.metricHint}>% standard pass in both</span>
</span>
<div className={styles.metricValue}>
<strong>{formatPercentage(school.english_maths_standard_pass_pct)}</strong>
</div>
</div>
)}
{school.rwm_expected_pct !== null && ( {school.rwm_expected_pct !== null && (
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricLabel}> <span className={styles.metricLabel}>
RWM Expected Reading, Writing &amp; Maths
<span className={styles.metricHint}>% meeting expected standard</span> <span className={styles.metricHint}>% meeting expected standard</span>
</span> </span>
<div className={styles.metricValue}> <div className={styles.metricValue}>

View File

@@ -5,15 +5,17 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison'; import { useComparison } from '@/hooks/useComparison';
import { PerformanceChart } from './PerformanceChart'; import { PerformanceChart } from './PerformanceChart';
import { SchoolMap } from './SchoolMap'; import { SchoolMap } from './SchoolMap';
import { MetricTooltip } from './MetricTooltip';
import type { import type {
School, SchoolResult, AbsenceData, School, SchoolResult, AbsenceData,
OfstedInspection, OfstedParentView, SchoolCensus, OfstedInspection, OfstedParentView, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics, SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils'; import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
import styles from './SchoolDetailView.module.css'; import styles from './SchoolDetailView.module.css';
@@ -37,19 +39,6 @@ const RC_CATEGORIES = [
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' }, { key: 'rc_sixth_form' as const, label: 'Sixth Form' },
]; ];
// 2023 national averages for context
const NATIONAL_AVG = {
rwm_expected: 60,
rwm_high: 8,
reading_expected: 73,
writing_expected: 71,
maths_expected: 73,
phonics_yr1: 79,
overall_absence: 6.7,
persistent_absence: 22,
class_size: 27,
per_pupil_spend: 6000,
};
function progressClass(val: number | null | undefined): string { function progressClass(val: number | null | undefined): string {
if (val == null) return ''; if (val == null) return '';
@@ -82,6 +71,23 @@ export function SchoolDetailView({
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// Phase detection
const phase = schoolInfo.phase ?? '';
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
const isPrimary = !isSecondary;
// National averages (fetched dynamically so they stay current)
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
useEffect(() => {
fetch('/api/national-averages')
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setNationalAvg(data); })
.catch(() => {});
}, []);
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
const handleComparisonToggle = () => { const handleComparisonToggle = () => {
if (isInComparison) { if (isInComparison) {
removeSchool(schoolInfo.urn); removeSchool(schoolInfo.urn);
@@ -108,13 +114,18 @@ export function SchoolDetailView({
const hasFinance = finance != null && finance.per_pupil_spend != null; const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
// Determine whether this school has KS2 or KS4 results to show
const hasKS2Results = latestResults != null && latestResults.rwm_expected_pct != null;
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
const hasAnyResults = hasKS2Results || hasKS4Results;
// Build section nav items dynamically — only sections with data // Build section nav items dynamically — only sections with data
const navItems: { id: string; label: string }[] = []; const navItems: { id: string; label: string }[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (parentView && parentView.total_responses != null && parentView.total_responses > 0) if (parentView && parentView.total_responses != null && parentView.total_responses > 0)
navItems.push({ id: 'parents', label: 'Parents' }); navItems.push({ id: 'parents', label: 'Parents' });
if (latestResults) navItems.push({ id: 'sats', label: 'SATs' }); if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' });
if (hasPhonics) navItems.push({ id: 'phonics', label: 'Phonics' }); if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
@@ -328,33 +339,48 @@ export function SchoolDetailView({
</section> </section>
)} )}
{/* SATs Results (merged with Subject Breakdown) */} {/* Results Section (SATs for primary, GCSEs for secondary) */}
{latestResults && ( {hasAnyResults && latestResults && (
<section id="sats" className={styles.card}> <section id="results" className={styles.card}>
<h2 className={styles.sectionTitle}>SATs Results ({formatAcademicYear(latestResults.year)})</h2> <h2 className={styles.sectionTitle}>
{isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
</h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison. {isSecondary
? 'GCSE results for Year 11 pupils. National averages shown for comparison.'
: 'End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.'}
</p> </p>
{/* Headline numbers: RWM combined */} {/* ── Primary / KS2 content ── */}
{hasKS2Results && (
<>
<div className={styles.metricsGrid}> <div className={styles.metricsGrid}>
{latestResults.rwm_expected_pct !== null && ( {latestResults.rwm_expected_pct !== null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Reading, Writing & Maths combined</div> <div className={styles.metricLabel}>
Reading, Writing &amp; Maths combined
<MetricTooltip metricKey="rwm_expected_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_expected_pct)}</div> <div className={styles.metricValue}>{formatPercentage(latestResults.rwm_expected_pct)}</div>
<div className={styles.metricHint}>National avg: {NATIONAL_AVG.rwm_expected}%</div> {primaryAvg.rwm_expected_pct != null && (
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)}
</div> </div>
)} )}
{latestResults.rwm_high_pct !== null && ( {latestResults.rwm_high_pct !== null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Exceeding expected level (RWM)</div> <div className={styles.metricLabel}>
Exceeding expected level (Reading, Writing &amp; Maths)
<MetricTooltip metricKey="rwm_high_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_high_pct)}</div> <div className={styles.metricValue}>{formatPercentage(latestResults.rwm_high_pct)}</div>
<div className={styles.metricHint}>National avg: {NATIONAL_AVG.rwm_high}%</div> {primaryAvg.rwm_high_pct != null && (
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
)}
</div> </div>
)} )}
</div> </div>
{/* Per-subject detail table */}
<div className={styles.metricGroupsGrid} style={{ marginTop: '1rem' }}> <div className={styles.metricGroupsGrid} style={{ marginTop: '1rem' }}>
<div className={styles.metricGroup}> <div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>Reading</h3> <h3 className={styles.metricGroupTitle}>Reading</h3>
@@ -373,7 +399,10 @@ export function SchoolDetailView({
)} )}
{latestResults.reading_progress !== null && ( {latestResults.reading_progress !== null && (
<div className={styles.metricRow}> <div className={styles.metricRow}>
<span className={styles.metricName}>Progress score</span> <span className={styles.metricName}>
Progress score
<MetricTooltip metricKey="reading_progress" />
</span>
<span className={`${styles.metricValue} ${progressClass(latestResults.reading_progress)}`}> <span className={`${styles.metricValue} ${progressClass(latestResults.reading_progress)}`}>
{formatProgress(latestResults.reading_progress)} {formatProgress(latestResults.reading_progress)}
</span> </span>
@@ -381,7 +410,10 @@ export function SchoolDetailView({
)} )}
{latestResults.reading_avg_score !== null && ( {latestResults.reading_avg_score !== null && (
<div className={styles.metricRow}> <div className={styles.metricRow}>
<span className={styles.metricName}>Average score</span> <span className={styles.metricName}>
Average score
<MetricTooltip metricKey="reading_avg_score" />
</span>
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span> <span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
</div> </div>
)} )}
@@ -405,7 +437,10 @@ export function SchoolDetailView({
)} )}
{latestResults.writing_progress !== null && ( {latestResults.writing_progress !== null && (
<div className={styles.metricRow}> <div className={styles.metricRow}>
<span className={styles.metricName}>Progress score</span> <span className={styles.metricName}>
Progress score
<MetricTooltip metricKey="writing_progress" />
</span>
<span className={`${styles.metricValue} ${progressClass(latestResults.writing_progress)}`}> <span className={`${styles.metricValue} ${progressClass(latestResults.writing_progress)}`}>
{formatProgress(latestResults.writing_progress)} {formatProgress(latestResults.writing_progress)}
</span> </span>
@@ -431,7 +466,10 @@ export function SchoolDetailView({
)} )}
{latestResults.maths_progress !== null && ( {latestResults.maths_progress !== null && (
<div className={styles.metricRow}> <div className={styles.metricRow}>
<span className={styles.metricName}>Progress score</span> <span className={styles.metricName}>
Progress score
<MetricTooltip metricKey="maths_progress" />
</span>
<span className={`${styles.metricValue} ${progressClass(latestResults.maths_progress)}`}> <span className={`${styles.metricValue} ${progressClass(latestResults.maths_progress)}`}>
{formatProgress(latestResults.maths_progress)} {formatProgress(latestResults.maths_progress)}
</span> </span>
@@ -439,7 +477,10 @@ export function SchoolDetailView({
)} )}
{latestResults.maths_avg_score !== null && ( {latestResults.maths_avg_score !== null && (
<div className={styles.metricRow}> <div className={styles.metricRow}>
<span className={styles.metricName}>Average score</span> <span className={styles.metricName}>
Average score
<MetricTooltip metricKey="maths_avg_score" />
</span>
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span> <span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
</div> </div>
)} )}
@@ -452,11 +493,105 @@ export function SchoolDetailView({
Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average. Progress scores measure how much pupils improved compared to similar schools nationally. Above 0 = better than average, below 0 = below average.
</p> </p>
)} )}
</>
)}
{/* ── Secondary / KS4 content ── */}
{hasKS4Results && (
<>
<div className={styles.metricsGrid}>
{latestResults.attainment_8_score !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Attainment 8
<MetricTooltip metricKey="attainment_8_score" />
</div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)}
</div>
)}
{latestResults.progress_8_score !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Progress 8
<MetricTooltip metricKey="progress_8_score" />
</div>
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score)}`}>
{formatProgress(latestResults.progress_8_score)}
</div>
<div className={styles.metricHint}>0 = national average</div>
</div>
)}
{latestResults.english_maths_standard_pass_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 4+
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
{secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
{latestResults.english_maths_strong_pass_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
English &amp; Maths Grade 5+
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
{secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)}
</div>
)}
</div>
{/* EBacc */}
{(latestResults.ebacc_entry_pct !== null || latestResults.ebacc_standard_pass_pct !== null) && (
<>
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
English Baccalaureate (EBacc)
<MetricTooltip metricKey="ebacc_entry_pct" />
</h3>
<div className={styles.metricTable}>
{latestResults.ebacc_entry_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Pupils entered for EBacc</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
</div>
)}
{latestResults.ebacc_standard_pass_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>
EBacc Grade 4+
<MetricTooltip metricKey="ebacc_standard_pass_pct" />
</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
</div>
)}
{latestResults.ebacc_strong_pass_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>
EBacc Grade 5+
<MetricTooltip metricKey="ebacc_strong_pass_pct" />
</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
</div>
)}
</div>
</>
)}
</>
)}
</section> </section>
)} )}
{/* Year 1 Phonics */} {/* Year 1 Phonics — primary only */}
{hasPhonics && phonics && ( {hasPhonics && isPrimary && phonics && (
<section id="phonics" className={styles.card}> <section id="phonics" className={styles.card}>
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2> <h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
@@ -466,7 +601,7 @@ export function SchoolDetailView({
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Passed the phonics check</div> <div className={styles.metricLabel}>Passed the phonics check</div>
<div className={styles.metricValue}>{formatPercentage(phonics.year1_phonics_pct)}</div> <div className={styles.metricValue}>{formatPercentage(phonics.year1_phonics_pct)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.phonics_yr1}%</div> <div className={styles.metricHint}>Phonics is a key early reading skill tested at end of Year 1</div>
</div> </div>
{phonics.year2_phonics_pct != null && ( {phonics.year2_phonics_pct != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
@@ -487,21 +622,31 @@ export function SchoolDetailView({
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Average class size</div> <div className={styles.metricLabel}>Average class size</div>
<div className={styles.metricValue}>{census.class_size_avg.toFixed(1)}</div> <div className={styles.metricValue}>{census.class_size_avg.toFixed(1)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.class_size} pupils</div> <div className={styles.metricHint}>Average number of pupils per class</div>
</div> </div>
)} )}
{absenceData?.overall_absence_rate != null && ( {absenceData?.overall_absence_rate != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Days missed (overall absence)</div> <div className={styles.metricLabel}>
Days missed (overall absence)
<MetricTooltip metricKey="overall_absence_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div> <div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.overall_absence}%</div> {primaryAvg.overall_absence_pct != null && (
<div className={styles.metricHint}>National avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
)}
</div> </div>
)} )}
{absenceData?.persistent_absence_rate != null && ( {absenceData?.persistent_absence_rate != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Regularly missing school</div> <div className={styles.metricLabel}>
Regularly missing school
<MetricTooltip metricKey="persistent_absence_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div> <div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
<div className={styles.metricHint}>National avg: ~{NATIONAL_AVG.persistent_absence}%. Missing 10%+ of sessions.</div> {primaryAvg.persistent_absence_pct != null && (
<div className={styles.metricHint}>National avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -515,7 +660,7 @@ export function SchoolDetailView({
<div className={styles.metricsGrid}> <div className={styles.metricsGrid}>
{admissions.published_admission_number != null && ( {admissions.published_admission_number != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Year 3 places per year</div> <div className={styles.metricLabel}>{isSecondary ? 'Year 7' : 'Year 3'} places per year</div>
<div className={styles.metricValue}>{admissions.published_admission_number}</div> <div className={styles.metricValue}>{admissions.published_admission_number}</div>
</div> </div>
)} )}
@@ -556,13 +701,19 @@ export function SchoolDetailView({
)} )}
{latestResults?.eal_pct != null && ( {latestResults?.eal_pct != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>English as an additional language</div> <div className={styles.metricLabel}>
English as an additional language
<MetricTooltip metricKey="eal_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.eal_pct)}</div> <div className={styles.metricValue}>{formatPercentage(latestResults.eal_pct)}</div>
</div> </div>
)} )}
{latestResults?.sen_support_pct != null && ( {latestResults?.sen_support_pct != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Pupils with additional needs (SEN support)</div> <div className={styles.metricLabel}>
Pupils receiving SEN support
<MetricTooltip metricKey="sen_support_pct" />
</div>
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div> <div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
</div> </div>
)} )}
@@ -610,7 +761,10 @@ export function SchoolDetailView({
{/* Local Area Context */} {/* Local Area Context */}
{hasDeprivation && deprivation && ( {hasDeprivation && deprivation && (
<section id="local-area" className={styles.card}> <section id="local-area" className={styles.card}>
<h2 className={styles.sectionTitle}>Local Area Context</h2> <h2 className={styles.sectionTitle}>
Local Area Context
<MetricTooltip metricKey="idaci_decile" />
</h2>
<div className={styles.deprivationDots}> <div className={styles.deprivationDots}>
{Array.from({ length: 10 }, (_, i) => ( {Array.from({ length: 10 }, (_, i) => (
<div <div
@@ -639,7 +793,7 @@ export function SchoolDetailView({
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Total spend per pupil per year</div> <div className={styles.metricLabel}>Total spend per pupil per year</div>
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div> <div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
<div className={styles.metricHint}>National avg: ~£{NATIONAL_AVG.per_pupil_spend.toLocaleString()}</div> <div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
</div> </div>
{finance.teacher_cost_pct != null && ( {finance.teacher_cost_pct != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
@@ -665,6 +819,7 @@ export function SchoolDetailView({
<PerformanceChart <PerformanceChart
data={yearlyData} data={yearlyData}
schoolName={schoolInfo.school_name} schoolName={schoolInfo.school_name}
isSecondary={isSecondary}
/> />
</div> </div>
{yearlyData.length > 1 && ( {yearlyData.length > 1 && (
@@ -675,22 +830,44 @@ export function SchoolDetailView({
<thead> <thead>
<tr> <tr>
<th>Year</th> <th>Year</th>
<th>Reading, Writing & Maths (expected %)</th> {isSecondary ? (
<>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>English &amp; Maths Grade 4+</th>
<th>English &amp; Maths Grade 5+</th>
</>
) : (
<>
<th>Reading, Writing &amp; Maths (expected %)</th>
<th>Exceeding expected (%)</th> <th>Exceeding expected (%)</th>
<th>Reading Progress</th> <th>Reading Progress</th>
<th>Writing Progress</th> <th>Writing Progress</th>
<th>Maths Progress</th> <th>Maths Progress</th>
</>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{yearlyData.map((result) => ( {yearlyData.map((result) => (
<tr key={result.year}> <tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td> <td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
{isSecondary ? (
<>
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
<td>{result.english_maths_strong_pass_pct !== null ? formatPercentage(result.english_maths_strong_pass_pct) : '-'}</td>
</>
) : (
<>
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td> <td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td> <td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
<td>{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td> <td>{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td>
<td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td> <td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td>
<td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td> <td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td>
</>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -98,6 +98,66 @@ export const METRIC_EXPLANATIONS: Record<string, MetricExplanation> = {
plain: 'Difference in attainment between disadvantaged pupils and their peers', plain: 'Difference in attainment between disadvantaged pupils and their peers',
detail: 'A smaller gap means the school is doing more to support disadvantaged pupils.', detail: 'A smaller gap means the school is doing more to support disadvantaged pupils.',
}, },
sen_support_pct: {
label: 'SEN Support',
plain: '% of pupils receiving SEN (Special Educational Needs) support without a formal plan',
detail: 'These pupils need extra help but do not yet have an Education, Health and Care Plan (EHCP).',
},
sen_ehcp_pct: {
label: 'Education, Health and Care Plan (EHCP)',
plain: '% of pupils with a formal EHCP — a legal plan for pupils with significant additional needs',
},
eal_pct: {
label: 'English as an Additional Language',
plain: '% of pupils whose first language is not English',
},
idaci_decile: {
label: 'Deprivation (IDACI)',
plain: 'How deprived the area around the school is. Decile 1 = most deprived 10% of areas in England.',
detail: 'IDACI stands for Income Deprivation Affecting Children Index. It measures the proportion of children living in low-income households in an area.',
},
// ── KS4 / GCSE metrics ────────────────────────────────────────────────
attainment_8_score: {
label: 'Attainment 8',
plain: 'Average grade across a pupil\'s best 8 GCSEs, including English and Maths',
detail: 'Each GCSE grade is converted to a points score (grade 9 = 9 points, grade 1 = 1 point). The national average is around 46.',
},
progress_8_score: {
label: 'Progress 8',
plain: 'How much pupils improved from their primary school results to GCSE, compared to similar pupils nationally',
detail: '0 = national average. Positive means better-than-expected progress; negative means lower-than-expected. A score above +0.5 is considered well above average.',
},
english_maths_standard_pass_pct: {
label: 'English & Maths — Grade 4+ (Standard Pass)',
plain: '% of pupils achieving at least a grade 4 in both English and Maths',
detail: 'Grade 4 is the minimum "standard pass". Employers and colleges often require grade 4 in English and Maths.',
},
english_maths_strong_pass_pct: {
label: 'English & Maths — Grade 5+ (Strong Pass)',
plain: '% of pupils achieving at least a grade 5 in both English and Maths',
detail: 'Grade 5 is a "strong pass". Many sixth forms and universities expect grade 5 in English and Maths.',
},
ebacc_entry_pct: {
label: 'EBacc Entry',
plain: '% of pupils who entered the English Baccalaureate — a set of GCSE subjects covering English, Maths, Sciences, a Language, and Humanities',
detail: 'EBacc entry keeps academic options open post-16. It is not a separate qualification.',
},
ebacc_standard_pass_pct: {
label: 'EBacc — Grade 4+ (Standard Pass)',
plain: '% of pupils achieving grade 4 or above across all EBacc subjects',
},
ebacc_strong_pass_pct: {
label: 'EBacc — Grade 5+ (Strong Pass)',
plain: '% of pupils achieving grade 5 or above across all EBacc subjects',
},
ebacc_avg_score: {
label: 'EBacc Average Score',
plain: 'Average points score across all EBacc subject entries',
},
gcse_grade_91_pct: {
label: 'GCSE Grade 91 %',
plain: '% of GCSE entries where a pupil achieved a grade between 9 (highest) and 1',
},
}; };
/** /**

View File

@@ -48,6 +48,13 @@ export interface School {
// Location search fields // Location search fields
distance?: number | null; distance?: number | null;
// Phase (Primary, Secondary, All-through, etc.)
phase?: string | null;
// KS4 card metrics
attainment_8_score?: number | null;
english_maths_standard_pass_pct?: number | null;
// GIAS enrichment fields // GIAS enrichment fields
website?: string | null; website?: string | null;
headteacher_name?: string | null; headteacher_name?: string | null;
@@ -225,6 +232,24 @@ export interface SchoolResult {
rwm_expected_3yr_pct: number | null; rwm_expected_3yr_pct: number | null;
reading_avg_3yr: number | null; reading_avg_3yr: number | null;
maths_avg_3yr: number | null; maths_avg_3yr: number | null;
// KS4 / GCSE metrics
attainment_8_score: number | null;
progress_8_score: number | null;
progress_8_lower_ci: number | null;
progress_8_upper_ci: number | null;
progress_8_english: number | null;
progress_8_maths: number | null;
progress_8_ebacc: number | null;
progress_8_open: number | null;
english_maths_strong_pass_pct: number | null;
english_maths_standard_pass_pct: number | null;
ebacc_entry_pct: number | null;
ebacc_strong_pass_pct: number | null;
ebacc_standard_pass_pct: number | null;
ebacc_avg_score: number | null;
gcse_grade_91_pct: number | null;
prior_attainment_avg: number | null;
} }
// ============================================================================ // ============================================================================
@@ -304,6 +329,13 @@ export interface Filters {
local_authorities: string[]; local_authorities: string[];
school_types: string[]; school_types: string[];
years: number[]; years: number[];
phases: string[];
}
export interface NationalAverages {
year: number;
primary: Record<string, number>;
secondary: Record<string, number>;
} }
// Backend returns filters directly, not wrapped // Backend returns filters directly, not wrapped
@@ -338,6 +370,7 @@ export interface SchoolSearchParams {
search?: string; search?: string;
local_authority?: string; local_authority?: string;
school_type?: string; school_type?: string;
phase?: string;
postcode?: string; postcode?: string;
radius?: number; radius?: number;
page?: number; page?: number;