Compare commits
59 Commits
fe31be34a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 87442788d4 | |||
| 62eeee5f7c | |||
| a7ab624a01 | |||
| 7e182e88b2 | |||
| 4cfae93a0d | |||
| 99dc5e7f8b | |||
| 763aef09f8 | |||
| d569a2afda | |||
| 1ca957499a | |||
| 9133ecdcd4 | |||
| 56ab1368b1 | |||
| 59f13a74f9 | |||
| 38d033f6a9 | |||
| 6045114ca2 | |||
| e39a79bab0 | |||
| a5be07ac0f | |||
| 4acfd21883 | |||
| 2a8ff29ccd | |||
| 976ebe752b | |||
| 1fb4b3ec5e | |||
| 675601869b | |||
| b7da3054e1 | |||
| c39256b1a0 | |||
| 9e0b004d93 | |||
| 795e2bae35 | |||
| 822d2afba1 | |||
| 9d34459191 | |||
| e52467ff5d | |||
| ae33bfe04b | |||
| 785cb72063 | |||
| 7e6ded29e2 | |||
| 3401654ab9 | |||
| 8154a59014 | |||
| 2e3456b21b | |||
| f05bbba613 | |||
| f6b9d650f8 | |||
| 3327728df0 | |||
| ac2d64caaf | |||
| bfff24fa5f | |||
| 34cd8ad26e | |||
| a27b9abd9f | |||
| 045dbc65b7 | |||
| 35deedcc16 | |||
| 5abab067a1 | |||
| 6d685b7e8a | |||
| 24ba65c829 | |||
| 3bf2e8f262 | |||
| 8ce34b3ecc | |||
| 9c50c49e1f | |||
| 177571f411 | |||
| 51310160a8 | |||
| 2c13b21360 | |||
| ad2fe5bbef | |||
| 58f8eae997 | |||
| 44fdcfa18b | |||
| b1e025d468 | |||
| 9ebb421307 | |||
| 8a6758b591 | |||
| 6d02d366ce |
@@ -0,0 +1,71 @@
|
||||
# Mobile design baseline
|
||||
|
||||
Mobile (≥55% of traffic) is the primary target for this app. Any new
|
||||
screen or component must be designed at the **360 px** viewport first
|
||||
and verified at three reference widths before merge.
|
||||
|
||||
## Reference viewports
|
||||
|
||||
| Width | Device class | Purpose |
|
||||
| --- | --- | --- |
|
||||
| 360 px | Low-end Android (Samsung A-series, older Pixels) | Hard floor — if it doesn't fit here it isn't shipping |
|
||||
| 390 px | iPhone 14 / 15 / 16 (38% of mobile traffic) | Primary iOS target |
|
||||
| 430 px | iPhone 16 Pro Max, large Android | Upper mobile bound |
|
||||
|
||||
## Acceptance checks for any screen change
|
||||
|
||||
Before raising a PR that touches user-visible UI, confirm at each
|
||||
reference width:
|
||||
|
||||
1. **No horizontal overflow.** `document.documentElement.scrollWidth ===
|
||||
window.innerWidth`. The most reliable check: in DevTools console run
|
||||
```js
|
||||
document.documentElement.scrollWidth - innerWidth
|
||||
```
|
||||
It must read `0`. Any positive number means something is bleeding
|
||||
past the right edge — usually a fixed-width element, an inline-block
|
||||
that didn't wrap, or a flex row missing `flex-wrap: wrap`.
|
||||
2. **Tap targets ≥ 44 × 44 px** on every interactive element (iOS Human
|
||||
Interface Guidelines minimum). Probe with:
|
||||
```js
|
||||
Array.from(document.querySelectorAll('a, button, [role=button], input, select'))
|
||||
.filter(el => el.offsetParent)
|
||||
.map(el => ({ t: el.innerText?.trim().slice(0,30), r: el.getBoundingClientRect() }))
|
||||
.filter(o => o.r.width < 44 || o.r.height < 44)
|
||||
```
|
||||
3. **No text below 11 px** in any visible-by-default block. Decorative
|
||||
demo content (illustrations, mocked previews) should either scale up
|
||||
or be hidden under the `640 px` breakpoint — see `MOB-04` for the
|
||||
pattern used on the home page's "What you'll see" section.
|
||||
4. **iOS Chrome bottom-bar parity.** The fixed `Navigation` bottom tab
|
||||
bar already compensates for the auto-hiding URL bar via the Visual
|
||||
Viewport API (`Navigation.tsx`). New fixed-bottom elements must
|
||||
either use the same offset (read `var(--mobile-bar-offset)`) or sit
|
||||
inside the existing tab-bar container.
|
||||
5. **Safe-area insets** on any new sticky/fixed chrome:
|
||||
`padding-bottom: env(safe-area-inset-bottom)` for bottom-pinned UI,
|
||||
`padding-inline: env(safe-area-inset-left/right)` for header-class
|
||||
chrome that runs full bleed.
|
||||
6. **`dvh`, not `vh`.** iOS Safari's collapsing toolbar makes raw `vh`
|
||||
units jump. Prefer `100dvh` (with a `100vh` fallback if you support
|
||||
older engines) for any height that needs to track the visible
|
||||
viewport.
|
||||
|
||||
## Component patterns
|
||||
|
||||
- **Hide-on-mobile decoration:** wrap with `@media (max-width: 640px) {
|
||||
.x { display: none; } }` — examples in `HomeView.module.css`
|
||||
(`.hiwVisual`), `MetricTooltip.module.css` (`.wrapper`).
|
||||
- **Right-edge scroll-fade for horizontal scrollers:**
|
||||
`mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);`
|
||||
Drop the fade when scrolled to the end with a JS-toggled class — see
|
||||
`SchoolDetailView.tsx`'s `sectionNavAtEnd` state for the pattern.
|
||||
|
||||
## Automation (future)
|
||||
|
||||
A Playwright regression test that asserts `docW === vw` at the three
|
||||
reference widths on `/`, `/rankings`, `/admissions`, `/compare`, and a
|
||||
representative `/school/:urn` page would catch overflow regressions
|
||||
immediately. Not added yet — Playwright isn't currently in the project
|
||||
dependency set, and the existing Jest setup doesn't compute layout.
|
||||
Worth adding if mobile overflow regressions recur.
|
||||
+87
-36
@@ -4,6 +4,7 @@ Serves primary and secondary school performance data for comparing schools.
|
||||
Uses real data from UK Government Compare School Performance downloads.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
@@ -12,6 +13,7 @@ import numpy as np
|
||||
import pandas as pd
|
||||
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
@@ -24,6 +26,7 @@ from .config import settings
|
||||
from .data_loader import (
|
||||
clear_cache,
|
||||
load_school_data,
|
||||
load_latest_school_data,
|
||||
geocode_single_postcode,
|
||||
get_supplementary_data,
|
||||
search_schools_typesense,
|
||||
@@ -164,6 +167,69 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
return response
|
||||
|
||||
|
||||
# Per-path Cache-Control rules. Keys are matched as path prefixes (longest wins).
|
||||
# Values: (max_age, s_maxage, stale_while_revalidate)
|
||||
CACHE_RULES: list[tuple[str, tuple[int, int, int]]] = [
|
||||
("/api/filters", (300, 86400, 604800)),
|
||||
("/api/metrics", (300, 86400, 604800)),
|
||||
("/api/national-averages", (300, 86400, 604800)),
|
||||
("/api/la-averages", (300, 86400, 604800)),
|
||||
("/api/data-info", (300, 86400, 604800)),
|
||||
("/api/schools/", (300, 3600, 86400)), # /api/schools/{urn}
|
||||
("/api/rankings", (60, 600, 3600)),
|
||||
("/api/compare", (60, 600, 3600)),
|
||||
("/api/schools", (30, 300, 1800)), # search list
|
||||
]
|
||||
|
||||
|
||||
def _cache_control_for_path(path: str) -> Optional[str]:
|
||||
# Longest-prefix match
|
||||
best: Optional[tuple[int, tuple[int, int, int]]] = None
|
||||
for prefix, vals in CACHE_RULES:
|
||||
if path.startswith(prefix) and (best is None or len(prefix) > best[0]):
|
||||
best = (len(prefix), vals)
|
||||
if best is None:
|
||||
return None
|
||||
max_age, s_maxage, swr = best[1]
|
||||
return f"public, max-age={max_age}, s-maxage={s_maxage}, stale-while-revalidate={swr}"
|
||||
|
||||
|
||||
class CacheAndETagMiddleware(BaseHTTPMiddleware):
|
||||
"""Set Cache-Control on cacheable API responses and serve 304s via ETag."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
# Only cache GETs that succeeded.
|
||||
if request.method != "GET" or response.status_code != 200:
|
||||
return response
|
||||
|
||||
cache_header = _cache_control_for_path(request.url.path)
|
||||
if cache_header is None:
|
||||
return response
|
||||
|
||||
# Drain body so we can hash it for ETag.
|
||||
body_chunks = []
|
||||
async for chunk in response.body_iterator:
|
||||
body_chunks.append(chunk)
|
||||
body = b"".join(body_chunks)
|
||||
|
||||
etag = '"' + hashlib.md5(body).hexdigest() + '"'
|
||||
headers = dict(response.headers)
|
||||
headers["Cache-Control"] = cache_header
|
||||
headers["ETag"] = etag
|
||||
headers["Vary"] = ", ".join(filter(None, [headers.get("Vary"), "Accept-Encoding"]))
|
||||
|
||||
inm = request.headers.get("if-none-match")
|
||||
if inm and inm == etag:
|
||||
# Strip content headers on 304.
|
||||
for h in ("Content-Length", "content-length", "Content-Type", "content-type"):
|
||||
headers.pop(h, None)
|
||||
return Response(status_code=304, headers=headers)
|
||||
|
||||
return Response(content=body, status_code=200, headers=headers, media_type=response.media_type)
|
||||
|
||||
|
||||
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Limit request body size to prevent DoS attacks."""
|
||||
|
||||
@@ -223,6 +289,8 @@ async def lifespan(app: FastAPI):
|
||||
print("Warning: No data in marts. Run the annual EES pipeline to populate KS2 data.")
|
||||
else:
|
||||
print(f"Data loaded successfully: {len(df)} records.")
|
||||
# Pre-compute the latest-year snapshot so the first search request is fast
|
||||
await asyncio.to_thread(load_latest_school_data)
|
||||
try:
|
||||
_sitemap_xml = build_sitemap()
|
||||
n = _sitemap_xml.count("<url>")
|
||||
@@ -250,9 +318,12 @@ app = FastAPI(
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Security middleware (order matters - these run in reverse order)
|
||||
# Middleware (Starlette runs the last-added middleware first on the way out,
|
||||
# so list outermost-last: GZip wraps everything and compresses the final body).
|
||||
app.add_middleware(CacheAndETagMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(RequestSizeLimitMiddleware)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=512)
|
||||
|
||||
# CORS middleware - restricted for production
|
||||
app.add_middleware(
|
||||
@@ -321,44 +392,17 @@ async def get_schools(
|
||||
phase = sanitize_search_input(phase)
|
||||
postcode = validate_postcode(postcode)
|
||||
|
||||
df = load_school_data()
|
||||
# Load the pre-computed latest-year snapshot (cached after first request / startup).
|
||||
# This avoids rebuilding the expensive groupby + prev-year merge on every search.
|
||||
df_latest = load_latest_school_data()
|
||||
|
||||
if df.empty:
|
||||
if df_latest.empty:
|
||||
return {"schools": [], "total": 0, "page": page, "page_size": 0}
|
||||
|
||||
# Use configured default if not specified
|
||||
if page_size is None:
|
||||
page_size = settings.default_page_size
|
||||
|
||||
# Schools with no performance data (special schools, PRUs, newly opened, etc.)
|
||||
# have NULL year from the LEFT JOIN — keep them but skip the groupby/trend logic.
|
||||
df_no_perf = df[df["year"].isna()].drop_duplicates(subset=["urn"])
|
||||
df = df[df["year"].notna()]
|
||||
|
||||
# Get unique schools (latest year data for each)
|
||||
latest_year = df.groupby("urn")["year"].max().reset_index()
|
||||
df_latest = df.merge(latest_year, on=["urn", "year"])
|
||||
|
||||
# Calculate trend by comparing to previous year
|
||||
# Get second-latest year for each school
|
||||
df_sorted = df.sort_values(["urn", "year"], ascending=[True, False])
|
||||
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
|
||||
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
|
||||
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
||||
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
||||
)
|
||||
if "attainment_8_score" in df_prev.columns:
|
||||
prev_rwm = prev_rwm.merge(
|
||||
df_prev[["urn", "attainment_8_score"]].rename(
|
||||
columns={"attainment_8_score": "prev_attainment_8_score"}
|
||||
),
|
||||
on="urn", how="outer"
|
||||
)
|
||||
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
||||
|
||||
# Merge back schools with no performance data
|
||||
df_latest = pd.concat([df_latest, df_no_perf], ignore_index=True)
|
||||
|
||||
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
|
||||
# in the correct phase(s) rather than being invisible to both filters.
|
||||
if phase:
|
||||
@@ -404,7 +448,8 @@ async def get_schools(
|
||||
# Location-based search (uses pre-geocoded data from database)
|
||||
search_coords = None
|
||||
if postcode:
|
||||
coords = geocode_single_postcode(postcode)
|
||||
# Offload the synchronous HTTP call to a thread so the event loop stays free
|
||||
coords = await asyncio.to_thread(geocode_single_postcode, postcode)
|
||||
if coords:
|
||||
search_coords = coords
|
||||
schools_df = schools_df.copy()
|
||||
@@ -683,7 +728,7 @@ async def get_national_averages(request: Request):
|
||||
"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",
|
||||
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct",
|
||||
]
|
||||
ks4_metrics = [
|
||||
"attainment_8_score", "progress_8_score",
|
||||
@@ -841,7 +886,12 @@ async def get_rankings(
|
||||
|
||||
# Return only relevant fields for rankings
|
||||
available_cols = [c for c in RANKING_COLUMNS if c in df.columns]
|
||||
df = df[available_cols]
|
||||
df = df[available_cols].copy()
|
||||
|
||||
# Surface the requested metric under a stable `value` key so the
|
||||
# frontend doesn't need to know each metric's column name. The raw
|
||||
# metric column is also kept in the row for callers that want it.
|
||||
df["value"] = df[metric]
|
||||
|
||||
return {
|
||||
"metric": metric,
|
||||
@@ -907,7 +957,8 @@ async def reload_data(
|
||||
Requires X-API-Key header with valid admin API key.
|
||||
"""
|
||||
clear_cache()
|
||||
load_school_data()
|
||||
await asyncio.to_thread(load_school_data)
|
||||
await asyncio.to_thread(load_latest_school_data)
|
||||
return {"status": "reloaded"}
|
||||
|
||||
|
||||
|
||||
+81
-8
@@ -15,7 +15,7 @@ from .database import SessionLocal, engine
|
||||
from .models import (
|
||||
DimSchool, DimLocation, KS2Performance,
|
||||
FactOfstedInspection, FactParentView, FactAdmissions,
|
||||
FactDeprivation, FactFinance,
|
||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||
)
|
||||
from .schemas import SCHOOL_TYPE_MAP
|
||||
|
||||
@@ -130,9 +130,9 @@ _MAIN_QUERY = text("""
|
||||
s.total_pupils AS gias_total_pupils,
|
||||
s.headteacher_name,
|
||||
s.website,
|
||||
s.ofsted_grade,
|
||||
s.ofsted_date,
|
||||
s.ofsted_framework,
|
||||
foi.ofsted_grade,
|
||||
foi.ofsted_date,
|
||||
foi.ofsted_framework,
|
||||
l.local_authority_name AS local_authority,
|
||||
l.local_authority_code,
|
||||
l.address_line1 AS address1,
|
||||
@@ -201,6 +201,15 @@ _MAIN_QUERY = text("""
|
||||
FROM marts.dim_school s
|
||||
JOIN marts.dim_location l ON s.urn = l.urn
|
||||
LEFT JOIN marts.fact_performance p ON s.urn = p.urn
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (urn)
|
||||
urn,
|
||||
overall_effectiveness AS ofsted_grade,
|
||||
inspection_date AS ofsted_date,
|
||||
framework AS ofsted_framework
|
||||
FROM marts.fact_ofsted_inspection
|
||||
ORDER BY urn, inspection_date DESC NULLS LAST
|
||||
) foi ON s.urn = foi.urn
|
||||
ORDER BY s.school_name, p.year
|
||||
""")
|
||||
|
||||
@@ -233,6 +242,8 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
||||
|
||||
# Cache for DataFrame
|
||||
_df_cache: Optional[pd.DataFrame] = None
|
||||
# Pre-computed latest-year snapshot (one row per school, with prev-year trend columns)
|
||||
_df_latest_cache: Optional[pd.DataFrame] = None
|
||||
|
||||
|
||||
def load_school_data() -> pd.DataFrame:
|
||||
@@ -251,10 +262,60 @@ def load_school_data() -> pd.DataFrame:
|
||||
return _df_cache
|
||||
|
||||
|
||||
def load_latest_school_data() -> pd.DataFrame:
|
||||
"""Return a cached one-row-per-school DataFrame at the latest available year.
|
||||
|
||||
The expensive groupby / merge / prev-year trend computation runs once at
|
||||
startup (or after a cache clear) rather than on every search request.
|
||||
Per-request filters (phase, gender, LA …) should be applied to the returned
|
||||
DataFrame's copy; they must NOT modify the cached object.
|
||||
"""
|
||||
global _df_latest_cache
|
||||
if _df_latest_cache is not None:
|
||||
return _df_latest_cache
|
||||
|
||||
df = load_school_data()
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
# Schools that have no performance rows (PRUs, new schools, etc.)
|
||||
df_no_perf = df[df["year"].isna()].drop_duplicates(subset=["urn"])
|
||||
df_with_perf = df[df["year"].notna()]
|
||||
|
||||
# Reduce to the latest year per school
|
||||
latest_year = df_with_perf.groupby("urn")["year"].max().reset_index()
|
||||
df_latest = df_with_perf.merge(latest_year, on=["urn", "year"])
|
||||
|
||||
# Attach previous-year metrics for trend arrows (second-latest year per school)
|
||||
df_sorted = df_with_perf.sort_values(["urn", "year"], ascending=[True, False])
|
||||
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
|
||||
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
|
||||
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
||||
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
||||
)
|
||||
if "attainment_8_score" in df_prev.columns:
|
||||
prev_rwm = prev_rwm.merge(
|
||||
df_prev[["urn", "attainment_8_score"]].rename(
|
||||
columns={"attainment_8_score": "prev_attainment_8_score"}
|
||||
),
|
||||
on="urn",
|
||||
how="outer",
|
||||
)
|
||||
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
||||
|
||||
# Merge back schools with no performance data
|
||||
df_latest = pd.concat([df_latest, df_no_perf], ignore_index=True)
|
||||
|
||||
print(f"Latest-snapshot cache built: {len(df_latest)} schools")
|
||||
_df_latest_cache = df_latest
|
||||
return _df_latest_cache
|
||||
|
||||
|
||||
def clear_cache():
|
||||
"""Clear all caches."""
|
||||
global _df_cache
|
||||
global _df_cache, _df_latest_cache
|
||||
_df_cache = None
|
||||
_df_latest_cache = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -401,8 +462,20 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
else None
|
||||
)
|
||||
|
||||
# Census (fact_pupil_characteristics — minimal until census columns are verified)
|
||||
result["census"] = None
|
||||
# Census (latest year of fact_pupil_characteristics)
|
||||
pc = safe_query(FactPupilCharacteristics, "urn", "year")
|
||||
result["census"] = (
|
||||
{
|
||||
"year": pc.year,
|
||||
"total_pupils": pc.total_pupils,
|
||||
"female_pupils": pc.female_pupils,
|
||||
"male_pupils": pc.male_pupils,
|
||||
"fsm_pct": pc.fsm_pct,
|
||||
"eal_pct": pc.eal_pct,
|
||||
}
|
||||
if pc
|
||||
else None
|
||||
)
|
||||
|
||||
# Admissions (latest year)
|
||||
a = safe_query(FactAdmissions, "urn", "year")
|
||||
@@ -410,7 +483,7 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
||||
{
|
||||
"year": a.year,
|
||||
"school_phase": a.school_phase,
|
||||
"published_admission_number": a.published_admission_number,
|
||||
"places_offered": a.places_offered,
|
||||
"total_applications": a.total_applications,
|
||||
"first_preference_applications": a.first_preference_applications,
|
||||
"first_preference_offers": a.first_preference_offers,
|
||||
|
||||
+2
-5
@@ -15,6 +15,7 @@ engine = create_engine(
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800, # recycle connections every 30 min to avoid stale TCP
|
||||
echo=False,
|
||||
)
|
||||
|
||||
@@ -34,13 +35,9 @@ def get_db():
|
||||
|
||||
@contextmanager
|
||||
def get_db_session():
|
||||
"""Context manager for non-FastAPI contexts."""
|
||||
"""Context manager for non-FastAPI contexts (read-only)."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+19
-1
@@ -179,7 +179,7 @@ class FactAdmissions(Base):
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
school_phase = Column(String(50))
|
||||
published_admission_number = Column(Integer)
|
||||
places_offered = Column(Integer)
|
||||
total_applications = Column(Integer)
|
||||
first_preference_applications = Column(Integer)
|
||||
first_preference_offers = Column(Integer)
|
||||
@@ -189,6 +189,24 @@ class FactAdmissions(Base):
|
||||
admissions_policy = Column(String(100))
|
||||
|
||||
|
||||
class FactPupilCharacteristics(Base):
|
||||
"""School pupil composition from EES census — one row per URN per year."""
|
||||
__tablename__ = "fact_pupil_characteristics"
|
||||
__table_args__ = (
|
||||
Index("ix_pupil_chars_urn_year", "urn", "year"),
|
||||
MARTS,
|
||||
)
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
phase_type_grouping = Column(String(50))
|
||||
total_pupils = Column(Integer)
|
||||
female_pupils = Column(Integer)
|
||||
male_pupils = Column(Integer)
|
||||
fsm_pct = Column(Float)
|
||||
eal_pct = Column(Float)
|
||||
|
||||
|
||||
class FactDeprivation(Base):
|
||||
"""IDACI deprivation index — one row per URN."""
|
||||
__tablename__ = "fact_deprivation"
|
||||
|
||||
@@ -547,6 +547,7 @@ SCHOOL_COLUMNS = [
|
||||
"admissions_policy",
|
||||
"ofsted_grade",
|
||||
"ofsted_date",
|
||||
"ofsted_framework",
|
||||
"latitude",
|
||||
"longitude",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
calculateTrend,
|
||||
isValidPostcode,
|
||||
debounce,
|
||||
buildOfstedListBadge,
|
||||
} from '@/lib/utils';
|
||||
|
||||
describe('formatPercentage', () => {
|
||||
@@ -102,3 +103,41 @@ describe('debounce', () => {
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { AdmissionsView } from '@/components/AdmissionsView';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'School Admissions Guide',
|
||||
description:
|
||||
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
|
||||
};
|
||||
|
||||
export default function AdmissionsPage() {
|
||||
return <AdmissionsView />;
|
||||
}
|
||||
@@ -20,8 +20,8 @@ export const metadata: Metadata = {
|
||||
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
|
||||
};
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
// can still use Next.js's per-call revalidate cache.
|
||||
|
||||
export default async function ComparePage({ searchParams }: ComparePageProps) {
|
||||
const { urns: urnsParam, metric: metricParam } = await searchParams;
|
||||
|
||||
@@ -92,7 +92,31 @@ body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
/* dvh (dynamic viewport) accounts for iOS Safari's collapsing toolbar;
|
||||
fall back to vh on older engines that don't recognise dvh. */
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
/* Suppress the iOS Safari grey tap flash — explicit :active states
|
||||
below carry the press feedback instead. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Provide a baseline press feedback for the most common interactive
|
||||
elements — replaces the suppressed default tap highlight. Buttons and
|
||||
.btn-* classes carry their own :active styles already; this handles
|
||||
plain anchors used as inline links and bare button elements. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
a:active,
|
||||
button:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reserve space for the fixed mobile bottom tab bar (56px + safe-area inset). */
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* Skip link — visible only on focus for keyboard users */
|
||||
@@ -1759,7 +1783,7 @@ body {
|
||||
|
||||
.modal-content {
|
||||
margin: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
max-height: calc(100dvh - 2rem);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -1829,7 +1853,7 @@ body {
|
||||
@media (max-width: 480px) {
|
||||
.modal-content {
|
||||
margin: 0.5rem;
|
||||
max-height: calc(100vh - 1rem);
|
||||
max-height: calc(100dvh - 1rem);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { DM_Sans, Playfair_Display } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
@@ -21,7 +21,24 @@ const playfairDisplay = Playfair_Display({
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
||||
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
||||
viewportFit: 'cover',
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'SchoolCompare',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
title: {
|
||||
default: 'SchoolCompare | Compare School Performance',
|
||||
template: '%s | SchoolCompare',
|
||||
@@ -57,10 +74,12 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||
<Script
|
||||
defer
|
||||
src="https://analytics.schoolcompare.co.uk/script.js"
|
||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||
data-performance="true"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
|
||||
+15
-6
@@ -5,6 +5,8 @@
|
||||
|
||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||
import { HomeView } from '@/components/HomeView';
|
||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||
import { EditorialSection } from '@/components/EditorialSection';
|
||||
|
||||
interface HomePageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -27,8 +29,9 @@ export const metadata = {
|
||||
description: 'Search and compare school performance across England',
|
||||
};
|
||||
|
||||
// Force dynamic rendering (no static generation at build time)
|
||||
export const dynamic = 'force-dynamic';
|
||||
// The page reads searchParams, which makes rendering dynamic by default.
|
||||
// We don't use `force-dynamic` here so the internal filter/data-info fetches
|
||||
// can still hit Next.js's data cache (configured per-call in lib/api.ts).
|
||||
|
||||
export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
// Await search params (Next.js 15 requirement)
|
||||
@@ -75,22 +78,28 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
|
||||
}
|
||||
|
||||
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
const total = dataInfo?.total_schools ?? null;
|
||||
return (
|
||||
<HomeView
|
||||
initialSchools={schoolsData}
|
||||
filters={filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||
totalSchools={dataInfo?.total_schools ?? null}
|
||||
filters={resolvedFilters}
|
||||
totalSchools={total}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data for home page:', error);
|
||||
|
||||
// Return error state with empty data
|
||||
const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
return (
|
||||
<HomeView
|
||||
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
||||
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||
filters={emptyFilters}
|
||||
totalSchools={null}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ export const metadata: Metadata = {
|
||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||
};
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
// can still use Next.js's per-call revalidate cache.
|
||||
|
||||
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
||||
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
|
||||
|
||||
const phase = phaseParam || 'primary';
|
||||
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_high_pct');
|
||||
const year = yearParam ? parseInt(yearParam) : undefined;
|
||||
|
||||
// Fetch rankings data with error handling
|
||||
|
||||
@@ -4,13 +4,48 @@
|
||||
* URL format: /school/138267-school-name-here
|
||||
*/
|
||||
|
||||
import { fetchSchoolDetails } from '@/lib/api';
|
||||
import { fetchSchoolDetails, fetchSchools } 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';
|
||||
|
||||
/**
|
||||
* Enumerate every school for static generation at build time.
|
||||
*
|
||||
* Set PRERENDER_SCHOOLS=1 in the build environment to enable. When disabled
|
||||
* (or when the API can't be reached), we return an empty list and the route
|
||||
* falls back to ISR on first request — `dynamicParams = true` covers it.
|
||||
*/
|
||||
export async function generateStaticParams(): Promise<Array<{ slug: string }>> {
|
||||
if (process.env.PRERENDER_SCHOOLS !== '1') return [];
|
||||
|
||||
const params: Array<{ slug: string }> = [];
|
||||
const PAGE_SIZE = 500;
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
try {
|
||||
do {
|
||||
const res = await fetchSchools({ page, page_size: PAGE_SIZE });
|
||||
for (const s of res.schools) {
|
||||
const path = schoolUrl(s.urn, s.school_name);
|
||||
const slug = path.replace('/school/', '');
|
||||
params.push({ slug });
|
||||
}
|
||||
totalPages = res.total_pages || 1;
|
||||
page += 1;
|
||||
} while (page <= totalPages);
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`generateStaticParams: prebuilding ${params.length} school pages.`);
|
||||
return params;
|
||||
}
|
||||
|
||||
interface SchoolPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
@@ -75,8 +110,10 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
}
|
||||
}
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
// ISR: regenerate at most once a week per slug. School data updates annually,
|
||||
// so a 7-day cache is plenty and gives sub-100ms TTFB on cache hits.
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
const { slug } = await params;
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
.shell {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.25rem 4rem;
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(0, 1fr);
|
||||
gap: 2.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
/* ─── In-page nav rail ────────────────────────────────── */
|
||||
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
padding-top: 3.75rem;
|
||||
}
|
||||
|
||||
.navLabel {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-bottom: 0.85rem;
|
||||
padding-left: 0.85rem;
|
||||
}
|
||||
|
||||
.navList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
border-left: 2px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
margin-left: -2px;
|
||||
border-left: 2px solid transparent;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-weight: 700;
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.navDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.navLinkActive .navDot {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
/* ─── Hero ───────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0 2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.eyebrowDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 2.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.heroSub {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ─── Countdown strip ────────────────────────────────── */
|
||||
|
||||
.countdownSection {
|
||||
padding: 0 0 2.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.stripHeader {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.stripLabel {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.countdownRail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.chipDeadline::before { background: var(--accent-coral, #e07256); }
|
||||
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
|
||||
|
||||
.chipUrgent {
|
||||
border-color: rgba(224, 114, 86, 0.4);
|
||||
background: rgba(224, 114, 86, 0.04);
|
||||
}
|
||||
|
||||
.chipTrack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.chipTrackDeadline { color: var(--accent-coral, #e07256); }
|
||||
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
||||
|
||||
.chipTrackDot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipDays {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 2.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.chipDeadline .chipDays,
|
||||
.chipUrgent .chipDays {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
}
|
||||
|
||||
.chipOffer .chipDays {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.chipDaysUnit {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: bottom;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.chipMilestone {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
line-height: 1.25;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.chipDate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
/* ─── Track (Secondary / Primary) ────────────────────── */
|
||||
|
||||
.track {
|
||||
margin-bottom: 3rem;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trackHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 1.75rem 2rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trackHeaderLeft {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trackKicker {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-coral, #e07256);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.trackTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.trackSub {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.trackDates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.trackDateRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
}
|
||||
|
||||
.trackDateLabel {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.trackDateVal {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
/* ─── Timeline ───────────────────────────────────────── */
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
padding: 1.5rem 2rem 1.75rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stepDotCol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.stepDeadline .stepDot {
|
||||
background: rgba(224, 114, 86, 0.15);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.stepOffer .stepDot {
|
||||
background: rgba(45, 125, 125, 0.15);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.stepLine {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.stepContent {
|
||||
padding-bottom: 1.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.step:last-child .stepContent {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.stepDate {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.stepDeadline .stepDate { color: var(--accent-coral, #e07256); }
|
||||
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
||||
|
||||
.stepTitle {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.3rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
|
||||
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
|
||||
|
||||
.stepBody {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── Tips ───────────────────────────────────────────── */
|
||||
|
||||
.tips {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tipsHeading {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.tipsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tipCard {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tipNumber {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.tipHeading {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tipBody {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: 0;
|
||||
background: var(--bg-primary, #faf8f3);
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
margin: 0 -1.25rem 1rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.navLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navList {
|
||||
flex-direction: row;
|
||||
gap: 0.35rem;
|
||||
border-left: none;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.navList::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 0.4rem 0.9rem;
|
||||
margin-left: 0;
|
||||
border-left: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
background: var(--text-primary, #1a1612);
|
||||
color: #fff;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.navLinkActive .navDot {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.countdownRail {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.trackHeader {
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
}
|
||||
|
||||
.trackDates {
|
||||
text-align: left;
|
||||
flex-direction: row;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tipsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import styles from './AdmissionsView.module.css';
|
||||
|
||||
/* ─── Date helpers ─────────────────────────────────────── */
|
||||
|
||||
function daysUntil(month: number, day: number): number {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const y = today.getFullYear();
|
||||
let target = new Date(y, month - 1, day);
|
||||
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function nextDate(month: number, day: number): Date {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const y = today.getFullYear();
|
||||
const target = new Date(y, month - 1, day);
|
||||
if (target <= today) return new Date(y + 1, month - 1, day);
|
||||
return target;
|
||||
}
|
||||
|
||||
function fmtDate(month: number, day: number): string {
|
||||
return nextDate(month, day).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── Data ─────────────────────────────────────────────── */
|
||||
|
||||
interface Chip {
|
||||
type: 'deadline' | 'offer';
|
||||
track: string;
|
||||
milestone: string;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
const CHIPS: Chip[] = [
|
||||
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||||
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||
{ type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 },
|
||||
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||
];
|
||||
|
||||
interface Step {
|
||||
date?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
highlight?: 'deadline' | 'offer';
|
||||
}
|
||||
|
||||
const SECONDARY_STEPS: Step[] = [
|
||||
{
|
||||
title: 'Check entry criteria',
|
||||
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely. Use school detail pages on SchoolCompare for admissions history.',
|
||||
},
|
||||
{
|
||||
date: 'September',
|
||||
title: 'Portal opens',
|
||||
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
|
||||
},
|
||||
{
|
||||
date: '31 October',
|
||||
title: 'Application deadline',
|
||||
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally — list schools in the genuine order you want them, not strategically.',
|
||||
highlight: 'deadline',
|
||||
},
|
||||
{
|
||||
date: '1 March',
|
||||
title: 'National Offer Day',
|
||||
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
|
||||
highlight: 'offer',
|
||||
},
|
||||
{
|
||||
date: '~15 March',
|
||||
title: 'Accept or decline',
|
||||
body: 'Respond by the deadline your council gives — typically around 15 March. Accepting does not prevent you from keeping a place on a waiting list for a preferred school.',
|
||||
},
|
||||
{
|
||||
title: 'Appeals',
|
||||
body: 'If unsuccessful, you can appeal within 20 school days of the refusal letter. Secondary appeals consider whether prejudice to the school outweighs your case — success rates vary.',
|
||||
},
|
||||
];
|
||||
|
||||
const PRIMARY_STEPS: Step[] = [
|
||||
{
|
||||
title: 'Research entry criteria',
|
||||
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
|
||||
},
|
||||
{
|
||||
date: 'September',
|
||||
title: 'Portal opens',
|
||||
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
|
||||
},
|
||||
{
|
||||
date: '15 January',
|
||||
title: 'Application deadline',
|
||||
body: 'List up to 3–6 schools (the number varies by council) in genuine preference order. The equal preference rule means all preferences are considered before any offers are made.',
|
||||
highlight: 'deadline',
|
||||
},
|
||||
{
|
||||
date: '16 April',
|
||||
title: 'National Offer Day',
|
||||
body: 'Results are published online. Reception offers are sent on 16 April (or the next working day if that falls on a weekend or bank holiday).',
|
||||
highlight: 'offer',
|
||||
},
|
||||
{
|
||||
date: '~1 May',
|
||||
title: 'Accept or decline',
|
||||
body: 'Respond by your council\'s deadline, typically around 1 May. Accepting secures the place while you wait to see if a preferred school\'s waiting list moves.',
|
||||
},
|
||||
{
|
||||
title: 'Appeals',
|
||||
body: 'Infant class-size appeals (Reception to Year 2) have a very narrow legal test and a low success rate. For Year 3+, appeals follow the same process as secondary.',
|
||||
},
|
||||
];
|
||||
|
||||
interface Tip {
|
||||
heading: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const TIPS: Tip[] = [
|
||||
{
|
||||
heading: 'Equal preference rule',
|
||||
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually want them.',
|
||||
},
|
||||
{
|
||||
heading: 'Late applications go to the back',
|
||||
body: 'Submit before the deadline even if your child does not turn the required age until later in the year. Late applicants are only considered after all on-time applications.',
|
||||
},
|
||||
{
|
||||
heading: 'Waiting lists',
|
||||
body: 'You can go on waiting lists for multiple schools simultaneously. Lists are ordered by admissions criteria, not when you joined. They can move significantly over the summer.',
|
||||
},
|
||||
];
|
||||
|
||||
/* ─── Component ────────────────────────────────────────── */
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'primary', label: 'Primary' },
|
||||
{ id: 'secondary', label: 'Secondary' },
|
||||
{ id: 'tips', label: 'Tips' },
|
||||
] as const;
|
||||
|
||||
type NavId = typeof NAV_ITEMS[number]['id'];
|
||||
|
||||
export function AdmissionsView() {
|
||||
const [chipDays, setChipDays] = useState<(number | null)[]>(CHIPS.map(() => null));
|
||||
const [activeId, setActiveId] = useState<NavId>('primary');
|
||||
|
||||
useEffect(() => {
|
||||
setChipDays(CHIPS.map(c => daysUntil(c.month, c.day)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sections = NAV_ITEMS
|
||||
.map(item => document.getElementById(item.id))
|
||||
.filter((el): el is HTMLElement => el !== null);
|
||||
|
||||
if (sections.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter(e => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||
if (visible[0]) setActiveId(visible[0].target.id as NavId);
|
||||
},
|
||||
{ rootMargin: '-30% 0px -55% 0px', threshold: [0, 0.25, 0.5, 0.75, 1] },
|
||||
);
|
||||
|
||||
sections.forEach(s => observer.observe(s));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, id: NavId) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 16;
|
||||
window.scrollTo({ top, behavior: 'smooth' });
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
|
||||
{/* In-page nav — sticky left rail on desktop, sticky top pills on mobile */}
|
||||
<aside className={styles.nav} aria-label="On this page">
|
||||
<div className={styles.navLabel}>On this page</div>
|
||||
<ul className={styles.navList}>
|
||||
{NAV_ITEMS.map(item => {
|
||||
const isActive = activeId === item.id;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
onClick={(e) => handleNavClick(e, item.id)}
|
||||
className={[styles.navLink, isActive ? styles.navLinkActive : ''].filter(Boolean).join(' ')}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
>
|
||||
<span className={styles.navDot} aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div className={styles.page}>
|
||||
|
||||
{/* Hero */}
|
||||
<section className={styles.hero}>
|
||||
<span className={styles.eyebrow}>
|
||||
<span className={styles.eyebrowDot} aria-hidden="true" />
|
||||
England · Primary & Secondary
|
||||
</span>
|
||||
<h1 className={styles.heroTitle}>School Admissions Guide</h1>
|
||||
<p className={styles.heroSub}>
|
||||
Everything parents need to know about applying for a school place in England — from opening dates to National Offer Day, with live countdowns to every key milestone.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Countdown strip */}
|
||||
<section className={styles.countdownSection}>
|
||||
<div className={styles.stripHeader}>
|
||||
<span className={styles.stripLabel}>Days until next milestone</span>
|
||||
</div>
|
||||
<div className={styles.countdownRail}>
|
||||
{CHIPS.map((chip, i) => {
|
||||
const days = chipDays[i];
|
||||
const isUrgent = days !== null && days <= 14;
|
||||
const chipClass = [
|
||||
styles.chip,
|
||||
chip.type === 'deadline' ? styles.chipDeadline : styles.chipOffer,
|
||||
isUrgent ? styles.chipUrgent : '',
|
||||
].filter(Boolean).join(' ');
|
||||
return (
|
||||
<div key={chip.milestone} className={chipClass}>
|
||||
<span className={[styles.chipTrack, chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer].join(' ')}>
|
||||
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||||
{chip.track}
|
||||
</span>
|
||||
<div>
|
||||
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||||
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||||
</div>
|
||||
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||||
<div className={styles.chipDate}>{days !== null ? fmtDate(chip.month, chip.day) : ''}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Primary track */}
|
||||
<section id="primary" className={styles.track} style={{ scrollMarginTop: '1rem' }}>
|
||||
<div className={styles.trackHeader}>
|
||||
<div className={styles.trackHeaderLeft}>
|
||||
<span className={styles.trackKicker}>Reception entry</span>
|
||||
<h2 className={styles.trackTitle}>Primary school admissions</h2>
|
||||
<p className={styles.trackSub}>For children starting Reception (Year R) in September. Applications are submitted in the autumn of the year before entry.</p>
|
||||
</div>
|
||||
<div className={styles.trackDates}>
|
||||
<div className={styles.trackDateRow}>
|
||||
<span className={styles.trackDateLabel}>Deadline</span>
|
||||
<span className={styles.trackDateVal}>{fmtDate(1, 15)}</span>
|
||||
</div>
|
||||
<div className={styles.trackDateRow}>
|
||||
<span className={styles.trackDateLabel}>Offer Day</span>
|
||||
<span className={styles.trackDateVal}>{fmtDate(4, 16)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol className={styles.timeline}>
|
||||
{PRIMARY_STEPS.map((step, i) => (
|
||||
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
|
||||
<div className={styles.stepDotCol}>
|
||||
<div className={styles.stepDot} />
|
||||
{i < PRIMARY_STEPS.length - 1 && <div className={styles.stepLine} />}
|
||||
</div>
|
||||
<div className={styles.stepContent}>
|
||||
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||
<div className={styles.stepTitle}>{step.title}</div>
|
||||
<p className={styles.stepBody}>{step.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Secondary track */}
|
||||
<section id="secondary" className={styles.track} style={{ scrollMarginTop: '1rem' }}>
|
||||
<div className={styles.trackHeader}>
|
||||
<div className={styles.trackHeaderLeft}>
|
||||
<span className={styles.trackKicker}>Year 7 entry</span>
|
||||
<h2 className={styles.trackTitle}>Secondary school admissions</h2>
|
||||
<p className={styles.trackSub}>For children starting secondary school (Year 7) in September. Applications are submitted in the autumn of Year 6.</p>
|
||||
</div>
|
||||
<div className={styles.trackDates}>
|
||||
<div className={styles.trackDateRow}>
|
||||
<span className={styles.trackDateLabel}>Deadline</span>
|
||||
<span className={styles.trackDateVal}>{fmtDate(10, 31)}</span>
|
||||
</div>
|
||||
<div className={styles.trackDateRow}>
|
||||
<span className={styles.trackDateLabel}>Offer Day</span>
|
||||
<span className={styles.trackDateVal}>{fmtDate(3, 1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol className={styles.timeline}>
|
||||
{SECONDARY_STEPS.map((step, i) => (
|
||||
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
|
||||
<div className={styles.stepDotCol}>
|
||||
<div className={styles.stepDot} />
|
||||
{i < SECONDARY_STEPS.length - 1 && <div className={styles.stepLine} />}
|
||||
</div>
|
||||
<div className={styles.stepContent}>
|
||||
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||
<div className={styles.stepTitle}>{step.title}</div>
|
||||
<p className={styles.stepBody}>{step.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Tips */}
|
||||
<section id="tips" className={styles.tips} style={{ scrollMarginTop: '1rem' }}>
|
||||
<h2 className={styles.tipsHeading}>Three things most parents get wrong</h2>
|
||||
<div className={styles.tipsGrid}>
|
||||
{TIPS.map((tip, i) => (
|
||||
<div key={i} className={styles.tipCard}>
|
||||
<div className={styles.tipNumber}>{String(i + 1).padStart(2, '0')}</div>
|
||||
<h3 className={styles.tipHeading}>{tip.heading}</h3>
|
||||
<p className={styles.tipBody}>{tip.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,31 +6,11 @@
|
||||
'use client';
|
||||
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ChartOptions,
|
||||
} from 'chart.js';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
interface ComparisonChartProps {
|
||||
comparisonData: Record<string, ComparisonData>;
|
||||
metric: string;
|
||||
|
||||
@@ -167,20 +167,12 @@
|
||||
background: var(--accent-coral-dark, #c9614a);
|
||||
}
|
||||
|
||||
/* Hidden on phones — the bottom tab bar's Compare badge already
|
||||
communicates count + destination, so the toast becomes redundant
|
||||
chrome that costs ~70px of permanent vertical space. Per-school
|
||||
removal still lives on the /compare page itself. */
|
||||
@media (max-width: 640px) {
|
||||
.toastContainer {
|
||||
bottom: 1.5rem;
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
|
||||
.toastContent {
|
||||
gap: 0;
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.toastActions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,15 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Right-edge fade so phone users see the comparison table scrolls.
|
||||
Otherwise the wider-than-viewport table silently clips. */
|
||||
@media (max-width: 640px) {
|
||||
.tableWrapper {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.comparisonTable {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
|
||||
@@ -5,16 +5,22 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { ComparisonChart } from './ComparisonChart';
|
||||
|
||||
const ComparisonChart = dynamic(
|
||||
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
||||
import { fetchComparison } from '@/lib/api';
|
||||
import { track } from '@/lib/analytics';
|
||||
import styles from './ComparisonView.module.css';
|
||||
|
||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||
@@ -59,6 +65,9 @@ export function ComparisonView({
|
||||
const [comparisonData, setComparisonData] = useState(initialData);
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||
// Tracks whether the user has explicitly clicked a phase tab.
|
||||
// While true, auto-phase detection is suppressed so manual selections aren't overridden.
|
||||
const phaseLockedByUser = useRef(false);
|
||||
|
||||
// Seed context from initialData when component mounts and localStorage is empty
|
||||
useEffect(() => {
|
||||
@@ -118,34 +127,83 @@ export function ComparisonView({
|
||||
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
||||
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
||||
|
||||
// Auto-select tab with more schools
|
||||
// Auto-select tab with more schools and sync the metric to match the detected phase.
|
||||
// This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct)
|
||||
// but the shortlisted schools are secondary — the phase tab switches but the metric
|
||||
// needs to follow, otherwise all secondary cards show "–" for a primary-only field.
|
||||
useEffect(() => {
|
||||
if (comparisonData && selectedSchools.length > 0) {
|
||||
if (secondarySchools.length > primarySchools.length) {
|
||||
setComparePhase('secondary');
|
||||
} else {
|
||||
setComparePhase('primary');
|
||||
}
|
||||
if (!comparisonData || selectedSchools.length === 0) return;
|
||||
if (phaseLockedByUser.current) return;
|
||||
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
||||
setComparePhase(newPhase);
|
||||
// Only reset the metric when it doesn't belong to the newly detected phase.
|
||||
// This preserves a correct metric that came from the URL (e.g. metric=attainment_8_score).
|
||||
const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
|
||||
const metricFitsPhase = metrics.some(
|
||||
(m) => m.key === selectedMetric && phaseCategories.includes(m.category)
|
||||
);
|
||||
if (!metricFitsPhase) {
|
||||
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||
}
|
||||
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||
phaseLockedByUser.current = true;
|
||||
setComparePhase(phase);
|
||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
setSelectedMetric(defaultMetric);
|
||||
};
|
||||
|
||||
// compare_viewed: fire once after the page has its first selection.
|
||||
// We watch `selectedSchools.length` going from 0 → ≥1 so the event is
|
||||
// sent only when there's actual content to view, not for empty arrivals.
|
||||
const compareViewedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (compareViewedRef.current) return;
|
||||
if (selectedSchools.length === 0) return;
|
||||
compareViewedRef.current = true;
|
||||
const primaryCount = selectedSchools.filter(s => s.phase?.toLowerCase().includes('primary')).length;
|
||||
const secondaryCount = selectedSchools.length - primaryCount;
|
||||
const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
|
||||
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
|
||||
}, [selectedSchools]);
|
||||
|
||||
const handleMetricChange = (metric: string) => {
|
||||
track('compare_metric_changed', { metric, phase: comparePhase });
|
||||
setSelectedMetric(metric);
|
||||
};
|
||||
|
||||
const handleRemoveSchool = (urn: number) => {
|
||||
removeSchool(urn);
|
||||
track('compare_school_removed', { urn, from: 'compare' });
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const url = window.location.href;
|
||||
const count = selectedSchools.length;
|
||||
const shareData = {
|
||||
title: 'School comparison · SchoolCompare',
|
||||
text: count > 0
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||
: 'SchoolCompare',
|
||||
url,
|
||||
};
|
||||
// Prefer the native share sheet on platforms that support it (iOS / Android).
|
||||
// canShare is feature-detected because Safari iOS exposes share() but
|
||||
// some configurations refuse the payload.
|
||||
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
await navigator.share(shareData);
|
||||
track('compare_shared', { method: 'native', school_count: count });
|
||||
return;
|
||||
} catch (err) {
|
||||
// User cancelled — bail silently. Any other error falls through to clipboard.
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
track('compare_shared', { method: 'clipboard', school_count: count });
|
||||
setShareConfirm(true);
|
||||
setTimeout(() => setShareConfirm(false), 2000);
|
||||
} catch { /* fallback: do nothing */ }
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Server component: pure markup, no client state.
|
||||
|
||||
import styles from './HomeView.module.css';
|
||||
|
||||
interface EditorialSectionProps {
|
||||
totalSchools: number | null;
|
||||
localAuthorityCount: number;
|
||||
}
|
||||
|
||||
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
|
||||
return (
|
||||
<section className={styles.editorial}>
|
||||
<div className={styles.editorialGrid}>
|
||||
<div className={styles.editorialText}>
|
||||
<div className={styles.editorialKicker}>About school data</div>
|
||||
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
|
||||
<p>
|
||||
School performance data in England is rich but fragmented. The Department for Education publishes
|
||||
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
|
||||
demographics — each in its own table, each with its own jargon.
|
||||
</p>
|
||||
<p>
|
||||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||
40-page inspection report.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.factbox}>
|
||||
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Schools covered</span>
|
||||
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Local authorities</span>
|
||||
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Phases</span>
|
||||
<span className={styles.factVal}>Primary & Secondary</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Latest results year</span>
|
||||
<span className={styles.factVal}>2024/25</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Historical data</span>
|
||||
<span className={styles.factVal}>2016–2025</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Metrics per school</span>
|
||||
<span className={styles.factVal}>40+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -261,6 +261,21 @@
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
/* When filters are applied, promote the toggle to a coral pill so users
|
||||
can see at a glance that the result list is being narrowed. */
|
||||
.advancedToggleActive {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral, #e07256);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.advancedToggleActive:hover {
|
||||
border-color: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.18));
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
.chevronUp {
|
||||
display: inline-block;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { isValidPostcode } from "@/lib/utils";
|
||||
import { track } from "@/lib/analytics";
|
||||
import type { Filters, ResultFilters } from "@/lib/types";
|
||||
import styles from "./FilterBar.module.css";
|
||||
|
||||
@@ -93,14 +94,36 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidPostcode(omniValue)) {
|
||||
const isPostcode = isValidPostcode(omniValue);
|
||||
const cleaned = omniValue.trim();
|
||||
|
||||
// Build a comma-separated active-filter list so a single search event
|
||||
// captures the whole intent (vs firing N events as filters are picked).
|
||||
const filters_active = [
|
||||
currentPhase && `phase=${currentPhase}`,
|
||||
currentLA && `la=${currentLA}`,
|
||||
currentType && `type=${currentType}`,
|
||||
currentGender && `gender=${currentGender}`,
|
||||
currentAdmissionsPolicy && `admissions=${currentAdmissionsPolicy}`,
|
||||
currentHasSixthForm && `sixth_form=${currentHasSixthForm}`,
|
||||
].filter(Boolean).join(',');
|
||||
|
||||
track('search_submitted', {
|
||||
query: isPostcode ? cleaned.toUpperCase() : cleaned.toLowerCase(),
|
||||
via: 'input',
|
||||
has_postcode: isPostcode,
|
||||
filters_active,
|
||||
filters_count: filters_active ? filters_active.split(',').length : 0,
|
||||
});
|
||||
|
||||
if (isPostcode) {
|
||||
updateURL({
|
||||
postcode: omniValue.trim().toUpperCase(),
|
||||
postcode: cleaned.toUpperCase(),
|
||||
radius: currentRadius || "1",
|
||||
search: "",
|
||||
});
|
||||
} else {
|
||||
updateURL({ search: omniValue.trim(), postcode: "", radius: "" });
|
||||
updateURL({ search: cleaned, postcode: "", radius: "" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,10 +222,11 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.advancedToggle}
|
||||
className={`${styles.advancedToggle}${hasActiveDropdownFilters ? ` ${styles.advancedToggleActive}` : ''}`}
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
aria-expanded={filtersOpen}
|
||||
>
|
||||
Advanced
|
||||
{hasActiveDropdownFilters ? 'Filters' : 'Advanced'}
|
||||
{hasActiveDropdownFilters
|
||||
? ` (${activeDropdownFilters.length})`
|
||||
: ""}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 3rem;
|
||||
grid-template-columns: 1.5fr 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,33 +15,47 @@ export function Footer() {
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.title}>SchoolCompare</h3>
|
||||
<p className={styles.description}>
|
||||
Compare primary and secondary schools across England.
|
||||
Compare primary and secondary schools across England. Free, independent, built on public data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>Product</h4>
|
||||
<ul className={styles.links}>
|
||||
<li><a href="/" className={styles.link}>Search schools</a></li>
|
||||
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
|
||||
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
|
||||
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>Resources</h4>
|
||||
<ul className={styles.links}>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.gov.uk/government/organisations/department-for-education"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.link}
|
||||
>
|
||||
Department for Education
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.gov.uk/school-performance-tables"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.link}
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="dfe"
|
||||
>
|
||||
School Performance Tables
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://reports.ofsted.gov.uk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.link}
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="ofsted"
|
||||
>
|
||||
Ofsted reports
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,32 +4,90 @@
|
||||
|
||||
.heroSection {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-top: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.heroEyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.heroEyebrowDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2.5rem;
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.85rem;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.015em;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
max-width: 840px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.heroEmph {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
margin: 0 auto;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 0.5rem;
|
||||
max-width: 680px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.heroDescription strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.heroSection {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
.heroTitle {
|
||||
font-size: 1.75rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.heroDescription {
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
||||
long descriptive paragraph — the h1 already names the product, and the
|
||||
search input is the primary action users came to perform. */
|
||||
@media (max-width: 640px) {
|
||||
.heroSection {
|
||||
padding-top: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.heroEyebrow,
|
||||
.heroDescription {
|
||||
display: none;
|
||||
}
|
||||
.heroTitle {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +142,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 280px);
|
||||
height: calc(100dvh - 280px);
|
||||
min-height: 520px;
|
||||
max-height: 800px;
|
||||
}
|
||||
@@ -409,7 +467,7 @@
|
||||
.mapViewContainer {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
height: calc(100vh - 280px);
|
||||
height: calc(100dvh - 280px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@@ -423,26 +481,67 @@
|
||||
}
|
||||
|
||||
.discoverySection {
|
||||
padding: 2rem var(--page-padding, 2rem);
|
||||
padding: 0.5rem 0 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.discoveryCount {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.discoveryCount strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.discoveryHints {
|
||||
color: var(--text-muted);
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.375rem;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
background: #235f5f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeBtnSpinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.geoError {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-coral, #e07256);
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quickSearches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -473,6 +572,568 @@
|
||||
border-color: var(--accent-coral);
|
||||
}
|
||||
|
||||
.exploringRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.exploringLabel {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.exploringChips {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exploringChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.95rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.exploringChip:hover {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chipDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── How it works section ─────────────────────────────── */
|
||||
|
||||
.howItWorks {
|
||||
padding: 3rem 0 1rem;
|
||||
}
|
||||
|
||||
.hiwHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hiwHeading {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hiwSub {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.hiwGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hiwCard {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.hiwVisual {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem;
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hiwPhaseBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.hiwPhaseLabel {
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.hiwPhaseLabel strong {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hiwCardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.hiwStep {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.hiwTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.hiwDesc {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Mini cascade (performance card) */
|
||||
.miniCascade {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.miniCascadeCol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.miniSubj {
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.miniRowHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.48rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.miniRowHead strong {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.miniTrack {
|
||||
height: 5px;
|
||||
border-radius: 2px;
|
||||
background: rgba(45, 125, 125, 0.08);
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.miniBarExp {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--accent-teal-light, #3a9e9e);
|
||||
}
|
||||
|
||||
.miniBarExc {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.miniNatPill {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: #fff;
|
||||
font-size: 0.4rem;
|
||||
font-weight: 700;
|
||||
padding: 0.05rem 0.2rem;
|
||||
border-radius: 3px;
|
||||
z-index: 2;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Attainment 8 mini bar */
|
||||
.att8Row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.att8BarWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.att8BarHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.5rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.att8Track {
|
||||
height: 7px;
|
||||
background: rgba(45, 125, 125, 0.08);
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.att8Fill {
|
||||
height: 100%;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.att8NatLine {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
bottom: -2px;
|
||||
width: 1.5px;
|
||||
background: rgba(224, 114, 86, 0.6);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.att8Score {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.att8Value {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.att8Delta {
|
||||
font-size: 0.55rem;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* Ofsted preview */
|
||||
.ofstedPreview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.2rem;
|
||||
}
|
||||
|
||||
.ofstedHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1.5px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.ofstedBullet {
|
||||
display: block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ofstedTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
.ofstedBadge {
|
||||
align-self: flex-start;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(45, 125, 125, 0.12);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.ofstedVerdict {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
.ofstedVerdict em {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.ofstedMeta {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
/* Compare preview */
|
||||
.comparePreview {
|
||||
width: 100%;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.compareHead {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr repeat(2, 1fr);
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
padding: 0.35rem 0.5rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.compareHeadCell {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.compareHeadLabel {
|
||||
font-family: inherit;
|
||||
font-size: 0.48rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.compareRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr repeat(2, 1fr);
|
||||
padding: 0.3rem 0.5rem;
|
||||
gap: 0.35rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.compareRowLabel {
|
||||
font-size: 0.55rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.compareRowVal {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
.compareRowValHi {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.compareFoot {
|
||||
font-size: 0.52rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hiwGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hiwHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.hiwHeading {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* On phones, hide the scaled-down preview visuals (text becomes illegible
|
||||
at this size) and let the explanatory text carry each card. */
|
||||
@media (max-width: 640px) {
|
||||
.hiwVisual {
|
||||
display: none;
|
||||
}
|
||||
.hiwCard {
|
||||
padding: 1rem 1.1rem;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.hiwTitle {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.hiwDesc {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Editorial section ───────────────────────────────── */
|
||||
|
||||
.editorial {
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.editorialGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 2rem;
|
||||
padding: 1.75rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.editorialText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.editorialKicker {
|
||||
font-size: 0.68rem;
|
||||
color: var(--accent-coral, #e07256);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editorialHeading {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.editorialText p {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.factbox {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.factboxHeading {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.factRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
font-size: 0.85rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.factRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.factKey {
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.factVal {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editorialGrid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1.25rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.resultsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -547,3 +1208,188 @@
|
||||
.loadMoreButton {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Admissions Countdown Strip
|
||||
========================================================= */
|
||||
|
||||
.admissionsStrip {
|
||||
padding: 1.5rem 0 2rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.stripHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stripLabel {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.stripCta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.stripCta:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.countdownRail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.countdownChip {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.countdownChip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.countdownChipDeadline::before {
|
||||
background: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.countdownChipOffer::before {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.countdownChipUrgent {
|
||||
border-color: rgba(224, 114, 86, 0.4);
|
||||
background: rgba(224, 114, 86, 0.04);
|
||||
}
|
||||
|
||||
.chipTrack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.chipTrackDeadline {
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.chipTrackOffer {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.chipTrackDot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipDays {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 2.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.countdownChipDeadline .chipDays,
|
||||
.countdownChipUrgent .chipDays {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
}
|
||||
|
||||
.countdownChipOffer .chipDays {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.chipDaysUnit {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: bottom;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.chipMilestone {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
line-height: 1.25;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.chipDate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.countdownRail {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* On phones the 2×2 grid cramped each chip so badly the "Secondary ·
|
||||
Deadline" track label dropped to 9.6px. Switch to a horizontal
|
||||
snap-scroller — each card is full-width-ish and stays readable,
|
||||
and the rightmost card peeks past the edge to signal there's more. */
|
||||
@media (max-width: 640px) {
|
||||
.countdownRail {
|
||||
display: flex;
|
||||
grid-template-columns: none;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
gap: 0.75rem;
|
||||
padding-right: 1.25rem;
|
||||
margin-inline: -1rem;
|
||||
padding-inline: 1rem;
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
}
|
||||
.countdownRail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.countdownChip {
|
||||
flex: 0 0 auto;
|
||||
width: 78%;
|
||||
min-width: 220px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.chipTrack {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { SchoolRow } from './SchoolRow';
|
||||
@@ -13,18 +13,57 @@ import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages } from '@/lib/api';
|
||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
import { schoolUrl } from '@/lib/utils';
|
||||
import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
||||
import { track } from '@/lib/analytics';
|
||||
import styles from './HomeView.module.css';
|
||||
|
||||
interface HomeViewProps {
|
||||
initialSchools: SchoolsResponse;
|
||||
filters: Filters;
|
||||
totalSchools?: number | null;
|
||||
// Slot props for static markup the server pre-renders so it stays out of
|
||||
// the client bundle. Server passes null when the landing sections shouldn't
|
||||
// show (e.g. an active search).
|
||||
howItWorks?: React.ReactNode;
|
||||
editorial?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) {
|
||||
function daysUntil(month: number, day: number): number {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const y = today.getFullYear();
|
||||
let target = new Date(y, month - 1, day);
|
||||
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatCountdownDate(month: number, day: number): string {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const y = today.getFullYear();
|
||||
let target = new Date(y, month - 1, day);
|
||||
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
interface CountdownChipData {
|
||||
type: 'deadline' | 'offer';
|
||||
track: string;
|
||||
milestone: string;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||||
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||
{ type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 },
|
||||
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||
];
|
||||
|
||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -37,9 +76,16 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
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 mapParamsRef = useRef<string>('');
|
||||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||||
const [geoError, setGeoError] = useState<string | null>(null);
|
||||
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
|
||||
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
|
||||
);
|
||||
|
||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||
const isLocationSearch = !!searchParams.get('postcode');
|
||||
@@ -51,11 +97,12 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
|| (!currentPhase && secondaryCount > primaryCount);
|
||||
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
|
||||
|
||||
// Reset pagination state when search params change
|
||||
// Reset pagination and map cache when search params change
|
||||
useEffect(() => {
|
||||
const newParamsStr = searchParams.toString();
|
||||
if (newParamsStr !== prevSearchParamsRef.current) {
|
||||
prevSearchParamsRef.current = newParamsStr;
|
||||
mapParamsRef.current = ''; // allow map to re-fetch for new search
|
||||
setAllSchools(initialSchools.schools);
|
||||
setCurrentPage(initialSchools.page);
|
||||
setHasMore(initialSchools.total_pages > 1);
|
||||
@@ -68,9 +115,13 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
setSelectedMapSchool(null);
|
||||
}, [resultsView, searchParams]);
|
||||
|
||||
// Fetch all schools within radius when map view is active
|
||||
// Fetch all schools within radius when map view is active.
|
||||
// Guard with a ref so toggling back to map never re-fetches the same params.
|
||||
useEffect(() => {
|
||||
if (resultsView !== 'map' || !isLocationSearch) return;
|
||||
const paramsKey = searchParams.toString();
|
||||
if (paramsKey === mapParamsRef.current) return;
|
||||
mapParamsRef.current = paramsKey;
|
||||
setIsLoadingMap(true);
|
||||
const params: Record<string, any> = {};
|
||||
searchParams.forEach((value, key) => { params[key] = value; });
|
||||
@@ -90,8 +141,23 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
.catch(() => {});
|
||||
}, [isSecondaryView, isMixedView]);
|
||||
|
||||
// Fetch national averages (supplementary — never blocks render)
|
||||
useEffect(() => {
|
||||
fetchNationalAverages()
|
||||
.then(data => setNationalAvgRwm(data.primary?.rwm_expected_pct ?? null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
|
||||
useEffect(() => {
|
||||
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
|
||||
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
|
||||
setSortedChips(withDays);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
track('results_load_more', { next_page: currentPage + 1 });
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const params: Record<string, any> = {};
|
||||
@@ -109,6 +175,54 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
}
|
||||
};
|
||||
|
||||
const handleNearMe = useCallback(() => {
|
||||
if (!navigator.geolocation) {
|
||||
track('near_me_used', { outcome: 'unsupported' });
|
||||
setGeoState('error');
|
||||
setGeoError('Geolocation is not supported by your browser. Enter a postcode instead.');
|
||||
return;
|
||||
}
|
||||
setGeoState('requesting');
|
||||
setGeoError(null);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const { latitude, longitude } = position.coords;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.postcodes.io/postcodes?lon=${longitude}&lat=${latitude}&limit=1`
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data.result && data.result.length > 0) {
|
||||
const postcode = data.result[0].postcode as string;
|
||||
setGeoState('idle');
|
||||
track('near_me_used', { outcome: 'granted' });
|
||||
track('search_submitted', { query: postcode, via: 'near_me', has_postcode: true, filters_active: '', filters_count: 0 });
|
||||
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
|
||||
} else {
|
||||
track('near_me_used', { outcome: 'no_postcode' });
|
||||
setGeoState('error');
|
||||
setGeoError('No postcode found near your location. Try entering one above.');
|
||||
}
|
||||
} catch {
|
||||
track('near_me_used', { outcome: 'lookup_error' });
|
||||
setGeoState('error');
|
||||
setGeoError('Could not look up your location. Please try again.');
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setGeoState('error');
|
||||
if (err.code === err.PERMISSION_DENIED) {
|
||||
track('near_me_used', { outcome: 'denied' });
|
||||
setGeoError('Location access was denied. Enter a postcode above to find nearby schools.');
|
||||
} else {
|
||||
track('near_me_used', { outcome: 'error' });
|
||||
setGeoError('Could not get your location. Please try again or enter a postcode.');
|
||||
}
|
||||
},
|
||||
{ timeout: 10000, maximumAge: 60000 }
|
||||
);
|
||||
}, [router]);
|
||||
|
||||
const sortedSchools = [...allSchools].sort((a, b) => {
|
||||
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
||||
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
||||
@@ -119,13 +233,42 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Empty-results sentinel: track when a search returns nothing.
|
||||
useEffect(() => {
|
||||
if (!isSearchActive) return;
|
||||
if (initialSchools.total !== 0) return;
|
||||
track('empty_results', {
|
||||
query_length: (searchParams.get('search') || '').length,
|
||||
has_postcode: !!searchParams.get('postcode'),
|
||||
});
|
||||
}, [initialSchools.total, isSearchActive, searchParams]);
|
||||
|
||||
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||||
// split which surface drives compare adds.
|
||||
const addSchoolFromSearch = useCallback((school: School) => {
|
||||
addSchool(school);
|
||||
track('compare_school_added', {
|
||||
urn: school.urn,
|
||||
from: 'search',
|
||||
selection_count_after: selectedSchools.length + 1,
|
||||
});
|
||||
}, [addSchool, selectedSchools.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.homeView}>
|
||||
{/* Combined Hero + Search and Filters */}
|
||||
{!isSearchActive && (
|
||||
<div className={styles.heroSection}>
|
||||
<h1 className={styles.heroTitle}>Find Local Schools</h1>
|
||||
<p className={styles.heroDescription}>Compare school results (SATs and GCSE), for thousands of schools across England</p>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated with 2024/25 results
|
||||
</span>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -138,17 +281,104 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
{/* Discovery section shown on landing page before any search */}
|
||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||
<div className={styles.discoverySection}>
|
||||
{totalSchools && <p className={styles.discoveryCount}><strong>{totalSchools.toLocaleString()}+</strong> primary and secondary schools across England</p>}
|
||||
<p className={styles.discoveryHints}>Try searching for a school name, or enter a postcode to find schools near you.</p>
|
||||
<div className={styles.quickSearches}>
|
||||
<span className={styles.quickSearchLabel}>Quick searches:</span>
|
||||
{['Manchester', 'Bristol', 'Leeds', 'Birmingham'].map(city => (
|
||||
<a key={city} href={`/?search=${city}`} className={styles.quickSearchChip}>{city}</a>
|
||||
))}
|
||||
<div className={styles.nearMeRow}>
|
||||
<button
|
||||
className={styles.nearMeBtn}
|
||||
onClick={handleNearMe}
|
||||
disabled={geoState === 'requesting'}
|
||||
>
|
||||
{geoState === 'requesting' ? (
|
||||
<>
|
||||
<span className={styles.nearMeBtnSpinner} aria-hidden="true" />
|
||||
Locating you…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z"/>
|
||||
<circle cx="12" cy="9" r="2.5"/>
|
||||
</svg>
|
||||
Schools near me
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{geoError && <p className={styles.geoError} role="alert">{geoError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admissions countdown strip — only on landing page */}
|
||||
{!isSearchActive && (
|
||||
<section className={styles.admissionsStrip}>
|
||||
<div className={styles.stripHeader}>
|
||||
<span className={styles.stripLabel}>Key admissions deadlines</span>
|
||||
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
|
||||
</div>
|
||||
<div
|
||||
className={styles.countdownRail}
|
||||
style={{
|
||||
opacity: sortedChips[0]?.days !== null ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{sortedChips.map(({ chip, days }) => {
|
||||
const isUrgent = days !== null && days <= 14;
|
||||
const chipClass = [
|
||||
styles.countdownChip,
|
||||
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
|
||||
isUrgent ? styles.countdownChipUrgent : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const trackClass = [
|
||||
styles.chipTrack,
|
||||
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
|
||||
].join(' ');
|
||||
return (
|
||||
<div key={chip.milestone} className={chipClass}>
|
||||
<span className={trackClass}>
|
||||
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||||
{chip.track}
|
||||
</span>
|
||||
<div>
|
||||
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||||
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||||
</div>
|
||||
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||||
<div className={styles.chipDate}>
|
||||
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Secondary discovery — moved below deadlines so the admissions
|
||||
countdown (time-sensitive) shows ahead of generic "explore" links. */}
|
||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||
<div className={styles.exploringRow}>
|
||||
<span className={styles.exploringLabel}>Start exploring</span>
|
||||
<div className={styles.exploringChips}>
|
||||
<a href="/rankings" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Top-rated primary schools
|
||||
</a>
|
||||
<a href="/rankings" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Top-rated secondary schools
|
||||
</a>
|
||||
<a href="/compare" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Start a comparison
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||||
{!isSearchActive && howItWorks}
|
||||
{!isSearchActive && editorial}
|
||||
|
||||
{/* Results Section */}
|
||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||
@@ -252,6 +482,8 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
center={initialSchools.location_info?.coordinates}
|
||||
referencePoint={initialSchools.location_info?.coordinates}
|
||||
onMarkerClick={setSelectedMapSchool}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
laAverages={laAverages}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.compactList}>
|
||||
@@ -262,8 +494,9 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
>
|
||||
<CompactSchoolItem
|
||||
school={school}
|
||||
onAddToCompare={addSchool}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -276,8 +509,9 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
<button className={styles.closeSheetBtn} onClick={() => setSelectedMapSchool(null)}>×</button>
|
||||
<CompactSchoolItem
|
||||
school={selectedMapSchool}
|
||||
onAddToCompare={addSchool}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,7 +527,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
key={school.urn}
|
||||
school={school}
|
||||
isLocationSearch={isLocationSearch}
|
||||
onAddToCompare={addSchool}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
onRemoveFromCompare={removeSchool}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
||||
@@ -303,9 +537,10 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
key={school.urn}
|
||||
school={school}
|
||||
isLocationSearch={isLocationSearch}
|
||||
onAddToCompare={addSchool}
|
||||
onAddToCompare={addSchoolFromSearch}
|
||||
onRemoveFromCompare={removeSchool}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
@@ -339,9 +574,28 @@ interface CompactSchoolItemProps {
|
||||
school: School;
|
||||
onAddToCompare: (school: School) => void;
|
||||
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 (
|
||||
<div className={styles.compactItem}>
|
||||
<div className={styles.compactItemContent}>
|
||||
@@ -355,24 +609,48 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.compactItemMeta}>
|
||||
{school.school_type && <span>{school.school_type}</span>}
|
||||
{school.local_authority && <span>{school.local_authority}</span>}
|
||||
{/* Ofsted badge */}
|
||||
<div style={{ marginBottom: '0.25rem' }}>
|
||||
<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>
|
||||
{/* Headline metric + delta */}
|
||||
<div className={styles.compactItemStats}>
|
||||
<span className={styles.compactStat}>
|
||||
<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'}
|
||||
{isSecondary
|
||||
? (school.attainment_8_score != null ? school.attainment_8_score.toFixed(1) : '-')
|
||||
: (school.rwm_expected_pct != null ? `${school.rwm_expected_pct}%` : '-')}
|
||||
</strong>
|
||||
{' '}
|
||||
{isSecondary ? 'Att 8' : 'RWM'}
|
||||
</span>
|
||||
<span className={styles.compactStat}>
|
||||
<strong>{school.total_pupils || '-'}</strong> pupils
|
||||
{rwmDelta != null && (
|
||||
<span style={deltaStyle}>
|
||||
{rwmDelta >= 2
|
||||
? `+${rwmDelta} pts vs national`
|
||||
: rwmDelta <= -2
|
||||
? `${rwmDelta} pts vs national`
|
||||
: '≈ national avg'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.compactItemActions}>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Server component: pure markup, no client state.
|
||||
// Rendered into HomeView via a slot prop so its JSX doesn't bloat the
|
||||
// HomeView client bundle.
|
||||
|
||||
import styles from './HomeView.module.css';
|
||||
|
||||
export function HowItWorksSection() {
|
||||
const miniCascade = [
|
||||
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
|
||||
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
|
||||
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
|
||||
];
|
||||
const compareRows = [
|
||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
||||
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={styles.howItWorks}>
|
||||
<div className={styles.hiwHeader}>
|
||||
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
||||
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
||||
</div>
|
||||
<div className={styles.hiwGrid}>
|
||||
{/* Card 1 — Performance */}
|
||||
<div className={styles.hiwCard}>
|
||||
<div className={styles.hiwVisual}>
|
||||
<div className={styles.hiwPhaseBlock}>
|
||||
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
||||
<div className={styles.miniCascade}>
|
||||
{miniCascade.map(({ subj, exp, exc, nat }) => (
|
||||
<div key={subj} className={styles.miniCascadeCol}>
|
||||
<div className={styles.miniSubj}>{subj}</div>
|
||||
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
||||
<div className={styles.miniTrack}>
|
||||
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
||||
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
||||
</div>
|
||||
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
|
||||
<div className={styles.miniTrack}>
|
||||
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hiwPhaseBlock}>
|
||||
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
||||
<div className={styles.att8Row}>
|
||||
<div className={styles.att8BarWrap}>
|
||||
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
|
||||
<div className={styles.att8Track}>
|
||||
<div className={styles.att8Fill} style={{ width: '62%' }} />
|
||||
<div className={styles.att8NatLine} style={{ left: '50%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.att8Score}>
|
||||
<div className={styles.att8Value}>62.4</div>
|
||||
<div className={styles.att8Delta}>+12.2 vs national</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hiwCardBody}>
|
||||
<div className={styles.hiwStep}>Performance</div>
|
||||
<div className={styles.hiwTitle}>Results against the national average</div>
|
||||
<p className={styles.hiwDesc}>For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2 — Ofsted */}
|
||||
<div className={styles.hiwCard}>
|
||||
<div className={styles.hiwVisual}>
|
||||
<div className={styles.ofstedPreview}>
|
||||
<div className={styles.ofstedHead}>
|
||||
<span className={styles.ofstedBullet} />
|
||||
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
||||
</div>
|
||||
<span className={styles.ofstedBadge}>OUTSTANDING</span>
|
||||
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
|
||||
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hiwCardBody}>
|
||||
<div className={styles.hiwStep}>Judgement</div>
|
||||
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
||||
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline — without opening a 40-page report.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3 — Compare */}
|
||||
<div className={styles.hiwCard}>
|
||||
<div className={styles.hiwVisual}>
|
||||
<div className={styles.comparePreview}>
|
||||
<div className={styles.compareHead}>
|
||||
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||||
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
||||
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
||||
</div>
|
||||
{compareRows.map(({ label, a, b, aHi }) => (
|
||||
<div key={label} className={styles.compareRow}>
|
||||
<span className={styles.compareRowLabel}>{label}</span>
|
||||
<span className={`${styles.compareRowVal} ${aHi ? styles.compareRowValHi : ''}`}>{a}</span>
|
||||
<span className={styles.compareRowVal}>{b}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hiwCardBody}>
|
||||
<div className={styles.hiwStep}>Compare</div>
|
||||
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
||||
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -25,9 +25,43 @@ interface LeafletMapInnerProps {
|
||||
zoom: number;
|
||||
referencePoint?: [number, number];
|
||||
onMarkerClick?: (school: School) => void;
|
||||
nationalAvgRwm?: number | null;
|
||||
laAverages?: Record<string, number | null>;
|
||||
}
|
||||
|
||||
export default function LeafletMapInner({ schools, center, zoom, referencePoint, 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 mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const refMarkerRef = useRef<L.Marker | null>(null);
|
||||
@@ -81,14 +115,73 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current);
|
||||
|
||||
// Create popup content
|
||||
const popupContent = `
|
||||
<div style="min-width: 200px;">
|
||||
<strong style="font-size: 14px; display: block; margin-bottom: 8px;">${school.school_name}</strong>
|
||||
${school.local_authority ? `<div style="font-size: 12px; color: #666; margin-bottom: 4px;">${school.local_authority}</div>` : ''}
|
||||
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">${school.school_type}</div>` : ''}
|
||||
<a href="${schoolUrl(school.urn, school.school_name)}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
|
||||
const badge = buildPopupBadge(school);
|
||||
const isSecondary = school.attainment_8_score != null;
|
||||
|
||||
// Phase label
|
||||
const rawPhase = (school.phase ?? '').toLowerCase();
|
||||
const phaseLabel =
|
||||
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);
|
||||
|
||||
@@ -114,7 +207,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
return () => {
|
||||
// Don't destroy map on every update, just clean markers
|
||||
};
|
||||
}, [schools, center, zoom, referencePoint, onMarkerClick]);
|
||||
}, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]);
|
||||
|
||||
// Cleanup map on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -81,3 +81,13 @@
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* On phones the icon was rendering at ~9px and the tooltip relied on
|
||||
:hover, which doesn't fire on touch. Rather than build a tap-to-show
|
||||
layer with backdrop dismissal, hide the helper entirely — the metric
|
||||
labels themselves carry the meaning. */
|
||||
@media (max-width: 640px) {
|
||||
.wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
/* Add the safe-area inset to horizontal padding so the header content
|
||||
clears the notch in landscape on iPhones. */
|
||||
padding-inline: max(1.5rem, env(safe-area-inset-left)) max(1.5rem, env(safe-area-inset-right));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -21,11 +23,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
/* Padded hit area so the logo link is ≥44×44 on touch */
|
||||
margin: -0.375rem -0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
transition: color 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
@@ -33,11 +39,17 @@
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.logoIcon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-weight: 700;
|
||||
@@ -126,21 +138,109 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
/* ─── Bottom tab bar (mobile only) ──────────────────────────────── */
|
||||
|
||||
.logoText {
|
||||
.bottomBar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
gap: 0.25rem;
|
||||
.tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
padding: 0.375rem 0.25rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
text-decoration: none;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
.tab:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.tabIconWrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tabBadge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border: 2px solid var(--bg-card, white);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
/* Hide the top text nav; the bottom bar takes over */
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-card, white);
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
box-shadow: 0 -2px 12px rgba(26, 22, 18, 0.06);
|
||||
/* Respect iPhone home-indicator (bottom) and notch (left/right in
|
||||
landscape) insets so the tab content never sits under system UI. */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
padding-inline: env(safe-area-inset-left, 0) env(safe-area-inset-right, 0);
|
||||
/* Compensate for iOS Chrome's auto-hiding URL bar — Navigation.tsx
|
||||
writes the offset based on the Visual Viewport API. translate3d
|
||||
(instead of translateY) forces hardware compositing so the bar
|
||||
doesn't lag/flicker during the toolbar animation. */
|
||||
transform: translate3d(0, var(--mobile-bar-offset, 0px), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,153 @@
|
||||
/**
|
||||
* Navigation Component
|
||||
* Main navigation header with active link highlighting
|
||||
* Top header nav for desktop; bottom tab bar for mobile (≤640px).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import styles from './Navigation.module.css';
|
||||
|
||||
type IconProps = { className?: string };
|
||||
|
||||
const SearchIcon = ({ className }: IconProps) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CompareIcon = ({ className }: IconProps) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h13l-3-3" />
|
||||
<path d="M20 17H7l3 3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RankingsIcon = ({ className }: IconProps) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M7 21V11" />
|
||||
<path d="M12 21V4" />
|
||||
<path d="M17 21v-7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AdmissionsIcon = ({ className }: IconProps) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function Navigation() {
|
||||
const pathname = usePathname();
|
||||
const { selectedSchools } = useComparison();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
if (path === '/') return pathname === '/';
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
|
||||
/**
|
||||
* iOS Chrome (and some Android browsers) auto-hide their URL bar on scroll,
|
||||
* which grows the visual viewport without changing the layout viewport.
|
||||
* `position: fixed; bottom: 0` sticks to the layout viewport, so our tab
|
||||
* bar appears to float mid-screen with a gap beneath it. Track the delta
|
||||
* via VisualViewport and apply it as a translate so the bar always sits
|
||||
* flush against the visible bottom edge.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const root = document.documentElement;
|
||||
const update = () => {
|
||||
const offset = window.innerHeight - (vv.height + vv.offsetTop);
|
||||
// Only positive offsets are meaningful (bar hidden → push down).
|
||||
root.style.setProperty('--mobile-bar-offset', `${Math.max(0, offset)}px`);
|
||||
};
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
root.style.removeProperty('--mobile-bar-offset');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const items = [
|
||||
{ href: '/', label: 'Search', Icon: SearchIcon },
|
||||
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
||||
{ href: '/rankings', label: 'Rankings', Icon: RankingsIcon },
|
||||
{ href: '/admissions', label: 'Admissions', Icon: AdmissionsIcon },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.container}>
|
||||
<Link href="/" className={styles.logo}>
|
||||
<div className={styles.logoIcon}>
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
||||
<span className={styles.logoIcon}>
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="20" cy="20" r="18" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M20 6L20 34M8 14L32 14M6 20L34 20M8 26L32 26" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="20" cy="20" r="3" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</span>
|
||||
<span className={styles.logoText}>SchoolCompare</span>
|
||||
</Link>
|
||||
|
||||
<nav className={styles.nav} aria-label="Main navigation">
|
||||
{items.map(({ href, label }) => {
|
||||
const active = isActive(href);
|
||||
const showBadge = href === '/compare' && selectedSchools.length > 0;
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
className={isActive('/') ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
||||
key={href}
|
||||
href={href}
|
||||
className={active ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
<Link
|
||||
href="/compare"
|
||||
className={isActive('/compare') ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
||||
>
|
||||
Compare
|
||||
{selectedSchools.length > 0 && (
|
||||
{label}
|
||||
{showBadge && (
|
||||
<span key={selectedSchools.length} className={styles.badge}>
|
||||
{selectedSchools.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href="/rankings"
|
||||
className={isActive('/rankings') ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
||||
>
|
||||
Rankings
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className={styles.bottomBar} aria-label="Main navigation">
|
||||
{items.map(({ href, label, Icon }) => {
|
||||
const active = isActive(href);
|
||||
const showBadge = href === '/compare' && selectedSchools.length > 0;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={active ? `${styles.tab} ${styles.tabActive}` : styles.tab}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<span className={styles.tabIconWrap}>
|
||||
<Icon className={styles.tabIcon} />
|
||||
{showBadge && (
|
||||
<span key={selectedSchools.length} className={styles.tabBadge} aria-label={`${selectedSchools.length} selected`}>
|
||||
{selectedSchools.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.tabLabel}>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
/* Fill the parent .chartContainer (which carries the height) so the
|
||||
canvas wrapper below can take 100% of a real number. Without this,
|
||||
.chartOuter auto-sizes to content and the canvas falls back to
|
||||
Chart.js's tiny default — leaving empty space below the chart. */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.trendSummary {
|
||||
@@ -17,7 +22,12 @@
|
||||
|
||||
.chartWrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* flex:1 instead of height:100% so the trend banner / chip strip
|
||||
above can take their natural size and the canvas fills the rest of
|
||||
the .chartOuter column. min-height:0 keeps flex from refusing to
|
||||
shrink the canvas below its content size. */
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -29,8 +39,104 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Mobile chip selector ────────────────────────────────────────────
|
||||
Hidden on desktop. Replaces the in-chart legend on phones — one
|
||||
metric at a time so the line variation is actually readable. */
|
||||
|
||||
.mobileChips {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileSubtitle {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.chipRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card, #fff);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.chip:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chipActive:active {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
}
|
||||
|
||||
.miniLegend {
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
.miniLegend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.miniDot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chartWrapper {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mobileChips {
|
||||
display: block;
|
||||
}
|
||||
/* Canvas needs an explicit height now that the parent .chartContainer
|
||||
flows naturally (its old fixed 220px was clipping the chip strip and
|
||||
pushing the disclosure link onto the plot area). */
|
||||
.chartWrapper {
|
||||
height: 220px;
|
||||
}
|
||||
/* The desktop "click the legend to show progress" hint is irrelevant
|
||||
once the chip row is the disclosure mechanism. */
|
||||
.chartHint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
/**
|
||||
* PerformanceChart Component
|
||||
* Displays school performance data over time using Chart.js
|
||||
* Displays school performance data over time using Chart.js.
|
||||
*
|
||||
* Desktop: full multi-series chart with dual y-axis (percentage + progress).
|
||||
* Mobile (≤640px): a chip selector switches the chart between one focused view
|
||||
* at a time — no dual axis, no legend, auto-scaled y-axis. Designed so the
|
||||
* actual variation in the data is visible on a phone instead of a flat line.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ChartOptions,
|
||||
ChartDataset,
|
||||
} from 'chart.js';
|
||||
import { ChartOptions, ChartDataset } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import type { SchoolResult } from '@/lib/types';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { track } from '@/lib/analytics';
|
||||
import styles from './PerformanceChart.module.css';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
||||
|
||||
interface NationalByYear {
|
||||
year: number;
|
||||
primary: Record<string, number>;
|
||||
@@ -34,17 +29,35 @@ interface PerformanceChartProps {
|
||||
data: SchoolResult[];
|
||||
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[];
|
||||
}
|
||||
|
||||
// Academic years when SATs/GCSEs were cancelled due to COVID
|
||||
const COVID_YEARS = new Set([201920, 202021]);
|
||||
|
||||
// Mobile chip definitions: which datasets render when each chip is active.
|
||||
// `series` keys reference the dataset labels so we can filter cleanly.
|
||||
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
||||
interface ChipDef {
|
||||
id: ChipId;
|
||||
label: string;
|
||||
/** Dataset labels (from the desktop dataset list below) this chip surfaces. */
|
||||
series: string[];
|
||||
}
|
||||
|
||||
const PRIMARY_CHIPS: ChipDef[] = [
|
||||
{ id: 'expected', label: 'At expected level', series: ['Reading, Writing & Maths expected %', 'National average'] },
|
||||
{ id: 'higher', label: 'Above expected level', series: ['Exceeding expected level'] },
|
||||
{ id: 'progress', label: 'Pupil progress', series: ['Reading progress', 'Writing progress', 'Maths progress'] },
|
||||
];
|
||||
|
||||
const SECONDARY_CHIPS: ChipDef[] = [
|
||||
{ id: 'attainment8', label: 'Attainment 8', series: ['Attainment 8', 'National average'] },
|
||||
{ id: 'em_pass', label: 'English & Maths grade 4+', series: ['English & Maths Grade 4+'] },
|
||||
{ id: 'progress8', label: 'Progress 8', series: ['Progress 8'] },
|
||||
];
|
||||
|
||||
export function PerformanceChart({
|
||||
data,
|
||||
isSecondary = false,
|
||||
@@ -55,8 +68,18 @@ export function PerformanceChart({
|
||||
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.
|
||||
// ── Mobile detection ─────────────────────────────────────────────────
|
||||
// Hydration-safe: SSR renders desktop; client flips to mobile after mount.
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 640px)');
|
||||
const update = () => setIsMobile(mq.matches);
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
// ── Build per-year national averages ─────────────────────────────────
|
||||
const natRefRwm: (number | null)[] = sortedData.map(d => {
|
||||
if (nationalByYear) {
|
||||
const match = nationalByYear.find(n => n.year === d.year);
|
||||
@@ -74,7 +97,7 @@ export function PerformanceChart({
|
||||
const hasNatRwm = natRefRwm.some(v => v != null);
|
||||
const hasNatAtt8 = natRefAtt8.some(v => v != null);
|
||||
|
||||
// ── Trend summary (primary only) ──────────────────────────────────────
|
||||
// ── Trend summary (primary only — references headline metric) ────────
|
||||
const trendSummary = (() => {
|
||||
if (isSecondary) return null;
|
||||
const rwm = sortedData.filter(d => d.rwm_expected_pct != null);
|
||||
@@ -87,18 +110,17 @@ export function PerformanceChart({
|
||||
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} Best year on record — ${latestPct}% met the expected standard in Reading, Writing & Maths`;
|
||||
}
|
||||
return `${arrow} Peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
||||
return `${arrow} Reading, Writing & Maths 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 ──────────────────────────────────────────────────────────
|
||||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||
const refLineStyle = {
|
||||
borderColor: 'rgba(90,80,70,0.35)',
|
||||
backgroundColor: 'transparent',
|
||||
@@ -109,7 +131,7 @@ export function PerformanceChart({
|
||||
order: 10,
|
||||
};
|
||||
|
||||
const datasets: ChartDataset<'line'>[] = isSecondary ? [
|
||||
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
||||
{
|
||||
label: 'Attainment 8',
|
||||
data: sortedData.map(d => d.attainment_8_score),
|
||||
@@ -211,7 +233,53 @@ export function PerformanceChart({
|
||||
},
|
||||
];
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
// ── Mobile chip state + filtered datasets ────────────────────────────
|
||||
const chips = isSecondary ? SECONDARY_CHIPS : PRIMARY_CHIPS;
|
||||
|
||||
// A chip is enabled only if at least one of its series has any real data.
|
||||
const chipHasData = (chip: ChipDef) =>
|
||||
chip.series.some(name => {
|
||||
const ds = allDatasets.find(d => d.label === name);
|
||||
return ds?.data?.some(v => v != null);
|
||||
});
|
||||
|
||||
const firstEnabledChip = chips.find(chipHasData)?.id ?? chips[0].id;
|
||||
const [activeChip, setActiveChip] = useState<ChipId>(firstEnabledChip);
|
||||
|
||||
const activeChipDef = chips.find(c => c.id === activeChip) ?? chips[0];
|
||||
|
||||
const mobileDatasets = useMemo(() => {
|
||||
return allDatasets
|
||||
.filter(ds => activeChipDef.series.includes(ds.label ?? ''))
|
||||
.map(ds => ({ ...ds, hidden: false, yAxisID: 'y' as const }));
|
||||
}, [activeChipDef, allDatasets]);
|
||||
|
||||
// Auto-scale Y axis for the mobile chart so variation is visible.
|
||||
// For percentage chips: clamp to 0–100 but tighten when data sits in a band.
|
||||
// For progress chips: centre on 0 with a small symmetric range.
|
||||
const mobileYBounds = useMemo(() => {
|
||||
const isProgress = activeChip === 'progress' || activeChip === 'progress8';
|
||||
const values: number[] = mobileDatasets.flatMap(ds =>
|
||||
(ds.data as Array<number | null | undefined>).filter((v): v is number => typeof v === 'number')
|
||||
);
|
||||
if (values.length === 0) return { min: 0, max: 100, isProgress };
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
if (isProgress) {
|
||||
const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5));
|
||||
return { min: -reach, max: reach, isProgress };
|
||||
}
|
||||
// Percentage: leave headroom but never widen below 0 / above 100.
|
||||
const padded = Math.max(5, Math.round((hi - lo) * 0.2));
|
||||
return {
|
||||
min: Math.max(0, Math.floor((lo - padded) / 5) * 5),
|
||||
max: Math.min(100, Math.ceil((hi + padded) / 5) * 5),
|
||||
isProgress,
|
||||
};
|
||||
}, [activeChip, mobileDatasets]);
|
||||
|
||||
// ── Chart options ────────────────────────────────────────────────────
|
||||
const desktopOptions: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
@@ -222,7 +290,6 @@ export function PerformanceChart({
|
||||
usePointStyle: true,
|
||||
padding: 14,
|
||||
font: { size: 12 },
|
||||
filter: item => item.text !== 'National average' || true,
|
||||
},
|
||||
},
|
||||
title: { display: false },
|
||||
@@ -237,36 +304,22 @@ export function PerformanceChart({
|
||||
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}`;
|
||||
return `${label}: ${ctx.parsed.y.toFixed(1)}${suffix}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: isSecondary ? 'Score / %' : 'Percentage (%)',
|
||||
font: { size: 11 },
|
||||
},
|
||||
min: 0,
|
||||
max: isSecondary ? undefined : 100,
|
||||
type: 'linear', display: true, position: 'left',
|
||||
title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
|
||||
min: 0, max: isSecondary ? undefined : 100,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: isSecondary ? 'Progress 8' : 'Progress score',
|
||||
font: { size: 11 },
|
||||
},
|
||||
type: 'linear', display: true, position: 'right',
|
||||
title: { display: true, text: isSecondary ? 'Progress 8' : 'Progress score', font: { size: 11 } },
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
@@ -277,19 +330,107 @@ export function PerformanceChart({
|
||||
},
|
||||
};
|
||||
|
||||
const mobileOptions: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 11 },
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
const label = ctx.dataset.label ?? '';
|
||||
if (ctx.parsed.y == null) return label;
|
||||
const suffix = mobileYBounds.isProgress ? '' : '%';
|
||||
return `${label}: ${ctx.parsed.y.toFixed(1)}${suffix}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear', display: true, position: 'left',
|
||||
min: mobileYBounds.min, max: mobileYBounds.max,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 10 }, autoSkip: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const subtitle = isSecondary
|
||||
? 'GCSE results · Year 11'
|
||||
: 'KS2 SATs · Reading, Writing & Maths';
|
||||
|
||||
return (
|
||||
<div className={styles.chartOuter}>
|
||||
{trendSummary && (
|
||||
<div className={styles.trendSummary}>{trendSummary}</div>
|
||||
)}
|
||||
<div className={styles.chartWrapper}>
|
||||
<Line data={{ labels: years, datasets }} options={options} />
|
||||
|
||||
{/* Mobile-only chip selector */}
|
||||
<div className={styles.mobileChips} aria-hidden={!isMobile}>
|
||||
<div className={styles.mobileSubtitle}>{subtitle}</div>
|
||||
<div className={styles.chipRow} role="tablist" aria-label="Select metric">
|
||||
{chips.map(chip => {
|
||||
const enabled = chipHasData(chip);
|
||||
const active = chip.id === activeChip;
|
||||
return (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
disabled={!enabled}
|
||||
onClick={() => {
|
||||
if (chip.id !== activeChip) {
|
||||
track('chart_metric_changed', { chip: chip.id, phase: isSecondary ? 'secondary' : 'primary', viewport: 'mobile' });
|
||||
}
|
||||
setActiveChip(chip.id);
|
||||
}}
|
||||
className={`${styles.chip}${active ? ` ${styles.chipActive}` : ''}`}
|
||||
title={!enabled ? 'No data for this school' : undefined}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartWrapper}>
|
||||
<Line
|
||||
data={{ labels: years, datasets: isMobile ? mobileDatasets : allDatasets }}
|
||||
options={isMobile ? mobileOptions : desktopOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* When the Progress chip is active on primary, show a tiny inline legend
|
||||
for the 3 sub-series (reading/writing/maths) — they share a unit and
|
||||
belong together. */}
|
||||
{isMobile && activeChip === 'progress' && (
|
||||
<div className={styles.miniLegend}>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(59,130,246)' }} />Reading</span>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(139,92,246)' }} />Writing</span>
|
||||
<span><span className={styles.miniDot} style={{ background: 'rgb(236,72,153)' }} />Maths</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCovidGap && (
|
||||
<p className={styles.covidNote}>
|
||||
* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Desktop-only hint about toggling progress in the legend */}
|
||||
{!isSecondary && (
|
||||
<p className={styles.chartHint}>
|
||||
Progress scores (Reading, Writing, Maths) are hidden by default — click them in the legend to show.
|
||||
|
||||
@@ -396,6 +396,16 @@
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* Long metric labels like "Reading, Writing & Maths Combined %" used to
|
||||
force the whole column wide; let them wrap onto 2 short lines with a
|
||||
tighter font so the value cell can stay compact. */
|
||||
.valueHeader {
|
||||
font-size: 0.625rem;
|
||||
white-space: normal;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.rankHeader {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
|
||||
import { track } from '@/lib/analytics';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import styles from './RankingsView.module.css';
|
||||
|
||||
@@ -74,11 +75,12 @@ export function RankingsView({
|
||||
};
|
||||
|
||||
const handlePhaseChange = (phase: string) => {
|
||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_high_pct';
|
||||
updateFilters({ phase, metric: defaultMetric });
|
||||
};
|
||||
|
||||
const handleMetricChange = (metric: string) => {
|
||||
track('metric_compared_in_rankings', { metric, phase: selectedPhase });
|
||||
updateFilters({ metric });
|
||||
};
|
||||
|
||||
@@ -98,6 +100,7 @@ export function RankingsView({
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
} as any);
|
||||
track('compare_school_added', { urn: ranking.urn, from: 'rankings' });
|
||||
};
|
||||
|
||||
// Get metric definition
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/* SatsChart — Cascade bar chart for KS2 SATs results */
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.75rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Individual subject column ── */
|
||||
.subjectChart {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subjectName {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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.35); /* --accent-coral at 35% */
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.natPill {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: #fff;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.35rem;
|
||||
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.35rem;
|
||||
padding-top: 0.9rem;
|
||||
}
|
||||
|
||||
.barRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.barHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.barTrack {
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
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-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.barLabelSuffix {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* ── Ruler ── */
|
||||
.ruler {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
margin-top: 0.3rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.rulerTick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 3px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.rulerLabel {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import styles from './SatsChart.module.css';
|
||||
|
||||
interface SubjectData {
|
||||
name: string;
|
||||
expectedPct: number | null;
|
||||
exceedingPct: number | null;
|
||||
nationalExpectedPct: number | null;
|
||||
}
|
||||
|
||||
interface SatsChartProps {
|
||||
subjects: SubjectData[];
|
||||
}
|
||||
|
||||
const RULER_TICKS = [0, 25, 50, 75, 100];
|
||||
const GRIDLINE_POSITIONS = [25, 50, 75];
|
||||
|
||||
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
||||
const expectedRef = useRef<HTMLDivElement>(null);
|
||||
const exceedingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
|
||||
|
||||
// Animate bars on mount
|
||||
useEffect(() => {
|
||||
const bars = [expectedRef.current, exceedingRef.current];
|
||||
bars.forEach((bar) => {
|
||||
if (!bar) return;
|
||||
const target = bar.dataset.width;
|
||||
bar.style.width = '0%';
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
bar.style.width = `${target}%`;
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [expectedPct, exceedingPct]);
|
||||
|
||||
if (expectedPct == null && exceedingPct == null) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.subjectChart}>
|
||||
<div className={styles.subjectName}>{name}</div>
|
||||
<div className={styles.chartArea}>
|
||||
{/* Gridlines */}
|
||||
<div className={styles.gridlines}>
|
||||
{GRIDLINE_POSITIONS.map((pct) => (
|
||||
<div key={pct} className={styles.gridline} style={{ left: `${pct}%` }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* National average marker */}
|
||||
{nationalExpectedPct != null && (
|
||||
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
|
||||
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bars */}
|
||||
<div className={styles.barGroup}>
|
||||
{expectedPct != null && (
|
||||
<div className={styles.barRow}>
|
||||
<div className={styles.barHeader}>
|
||||
<span className={styles.barLabelSuffix}>Expected</span>
|
||||
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className={styles.barTrack}>
|
||||
<div
|
||||
ref={expectedRef}
|
||||
className={`${styles.bar} ${styles.barExpected}`}
|
||||
data-width={expectedPct}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{exceedingPct != null && (
|
||||
<div className={styles.barRow}>
|
||||
<div className={styles.barHeader}>
|
||||
<span className={styles.barLabelSuffix}>Exceeding</span>
|
||||
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className={styles.barTrack}>
|
||||
<div
|
||||
ref={exceedingRef}
|
||||
className={`${styles.bar} ${styles.barExceeding}`}
|
||||
data-width={exceedingPct}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ruler */}
|
||||
<div className={styles.ruler}>
|
||||
{RULER_TICKS.map((pct, i) => (
|
||||
<span key={pct}>
|
||||
<div className={styles.rulerTick} style={{ left: `${pct}%` }} />
|
||||
<div
|
||||
className={`${styles.rulerLabel} ${i === 0 ? styles.rulerLabelFirst : ''} ${i === RULER_TICKS.length - 1 ? styles.rulerLabelLast : ''}`}
|
||||
style={{ left: `${pct}%` }}
|
||||
>
|
||||
{pct}%
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SatsChart({ subjects }: SatsChartProps) {
|
||||
const visibleSubjects = subjects.filter(
|
||||
(s) => s.expectedPct != null || s.exceedingPct != null
|
||||
);
|
||||
|
||||
if (visibleSubjects.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.container}>
|
||||
{visibleSubjects.map((subject) => (
|
||||
<SubjectColumn key={subject.name} subject={subject} />
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.legend}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
|
||||
Expected standard
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
|
||||
Exceeding / high score
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
|
||||
National average
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
}
|
||||
|
||||
.meta {
|
||||
@@ -83,6 +83,58 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Gender split card — sits in the Pupils & Inclusion heroStatGrid */
|
||||
.genderSplitValue {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.genderSplitGirls {
|
||||
color: #b45778;
|
||||
}
|
||||
|
||||
.genderSplitBoys {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.genderSplitLabel {
|
||||
font-family: var(--font-body, inherit);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.genderSplitSep {
|
||||
color: var(--border-color, #c8beb0);
|
||||
font-weight: 400;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.genderBar {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.genderBarGirls {
|
||||
background: #b45778;
|
||||
}
|
||||
|
||||
.genderBarBoys {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -125,7 +177,7 @@
|
||||
/* ── Sticky Section Navigation ──────────────────────── */
|
||||
.sectionNav {
|
||||
position: sticky;
|
||||
top: 3.5rem;
|
||||
top: 4rem;
|
||||
z-index: 10;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
@@ -138,6 +190,8 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
scroll-snap-type: x proximity;
|
||||
scroll-padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.sectionNav::-webkit-scrollbar {
|
||||
@@ -150,6 +204,21 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Right-edge fade so users see there's more to scroll to. */
|
||||
@media (max-width: 640px) {
|
||||
.sectionNav {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
padding-right: 1.75rem;
|
||||
}
|
||||
|
||||
/* When scrolled to the end, drop the fade so the last item isn't dimmed. */
|
||||
.sectionNav.atEnd {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionNavBack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -180,7 +249,8 @@
|
||||
}
|
||||
|
||||
.sectionNavLink {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -189,6 +259,16 @@
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sectionNavLink,
|
||||
.sectionNavBack {
|
||||
min-height: 36px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionNavLink:hover {
|
||||
@@ -228,7 +308,7 @@
|
||||
margin-bottom: 0.875rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
@@ -236,7 +316,7 @@
|
||||
}
|
||||
|
||||
.sectionTitle::before {
|
||||
content: '';
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
@@ -323,6 +403,70 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Hero stat cards (RWM combined, Pupils & Inclusion, etc.) ── */
|
||||
/* Larger teal-tinted cards with Playfair serif numbers — reserved for
|
||||
the top-of-section headline metrics. Use .metricCard for denser
|
||||
secondary metrics. */
|
||||
.heroStatGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.heroStatCard {
|
||||
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
|
||||
border: 1px solid rgba(45, 125, 125, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.heroStatLabel {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted, #6d685f);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.heroStatValue {
|
||||
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||
font-size: 2.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.heroStatHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
font-style: normal;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.heroStatGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heroStatValue {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress score colour coding */
|
||||
.progressPositive {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
@@ -491,17 +635,44 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ofstedGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofstedGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||
.ofstedGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||
.ofstedGrade4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
.ofstedGrade1 {
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
.ofstedGrade2 {
|
||||
background: rgba(60, 140, 60, 0.12);
|
||||
color: #3c8c3c;
|
||||
}
|
||||
.ofstedGrade3 {
|
||||
background: var(--accent-gold-bg);
|
||||
color: #b8920e;
|
||||
}
|
||||
.ofstedGrade4 {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
/* Report Card grade colours (5-level scale, lower = better) */
|
||||
.rcGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); } /* Exceptional */
|
||||
.rcGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; } /* Strong */
|
||||
.rcGrade3 { background: var(--accent-gold-bg); color: #b8920e; } /* Expected standard */
|
||||
.rcGrade4 { background: rgba(249, 115, 22, 0.12); color: #c2410c; } /* Needs attention */
|
||||
.rcGrade5 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); } /* Urgent improvement */
|
||||
.rcGrade1 {
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
} /* Exceptional */
|
||||
.rcGrade2 {
|
||||
background: rgba(60, 140, 60, 0.12);
|
||||
color: #3c8c3c;
|
||||
} /* Strong */
|
||||
.rcGrade3 {
|
||||
background: var(--accent-gold-bg);
|
||||
color: #b8920e;
|
||||
} /* Expected standard */
|
||||
.rcGrade4 {
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
color: #c2410c;
|
||||
} /* Needs attention */
|
||||
.rcGrade5 {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral, #e07256);
|
||||
} /* Urgent improvement */
|
||||
|
||||
/* Safeguarding value (used inside a standard metricCard) */
|
||||
.safeguardingMet {
|
||||
@@ -664,7 +835,6 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.headerContent {
|
||||
@@ -685,11 +855,23 @@
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Pills wrap horizontally instead of stacking — short tokens like
|
||||
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full
|
||||
rows of empty horizontal space. */
|
||||
.meta {
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Secondary header info (headteacher, website, pupil count, trust)
|
||||
isn't needed above the fold on phones — pupil count lives in the
|
||||
Pupils & Inclusion section, website is one scroll away. Reclaim
|
||||
the ~3 vertical lines so the actual metrics surface sooner. */
|
||||
.headerDetails {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.metricsGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@@ -698,8 +880,12 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* PerformanceChart on mobile now stacks: trend banner → chip subtitle →
|
||||
chip row → canvas → mini-legend → COVID footnote. The container must
|
||||
flow naturally instead of clipping to a fixed 220px — PerformanceChart's
|
||||
own .chartWrapper carries the canvas height. */
|
||||
.chartContainer {
|
||||
height: 220px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
@@ -786,11 +972,21 @@
|
||||
/* Hero tone scheme — independent of the .ofstedGrade{N} / .rcGrade{N} badges
|
||||
so the same tone class can be applied to a chip (background tint + border)
|
||||
or a serif number (colour only) without one bleeding into the other. */
|
||||
.tone-teal { --hero-tone: var(--accent-teal, #2d7d7d); }
|
||||
.tone-green { --hero-tone: #3c8c3c; }
|
||||
.tone-gold { --hero-tone: var(--accent-gold, #c9a227); }
|
||||
.tone-coral { --hero-tone: var(--accent-coral, #e07256); }
|
||||
.tone-neutral { --hero-tone: var(--text-muted, #8a847a); }
|
||||
.tone-teal {
|
||||
--hero-tone: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
.tone-green {
|
||||
--hero-tone: #3c8c3c;
|
||||
}
|
||||
.tone-gold {
|
||||
--hero-tone: var(--accent-gold, #c9a227);
|
||||
}
|
||||
.tone-coral {
|
||||
--hero-tone: var(--accent-coral, #e07256);
|
||||
}
|
||||
.tone-neutral {
|
||||
--hero-tone: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.heroChip.tone-teal,
|
||||
.heroChip.tone-green,
|
||||
@@ -825,7 +1021,7 @@
|
||||
|
||||
.heroStatNumber,
|
||||
.heroStatNumberSerif {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary, #1a1612);
|
||||
@@ -885,6 +1081,20 @@
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* Collapse the "Ofsted pending / No inspection on record" empty state
|
||||
into a single compact line on phones — it's a non-result, not worth
|
||||
a full hero card. */
|
||||
.heroChip[data-ofsted-state="none"] {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.heroChip[data-ofsted-state="none"] .heroChipSub {
|
||||
display: none;
|
||||
}
|
||||
.heroChip[data-ofsted-state="none"] .heroChipTitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.heroStats {
|
||||
gap: 1rem 1.5rem;
|
||||
}
|
||||
@@ -894,3 +1104,225 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── RWM bridge ("Why is combined lower?") ── */
|
||||
.rwmBridge {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin: 0.5rem 0 1.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rwmBridgeIcon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
.rwmBridgeBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rwmBridgeText {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rwmBridgeText strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rwmBridgeMath {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||
color: var(--text-muted, #6d685f);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.rwmBridgeMath strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rwmBridgeMathSep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Progress scores row (below SatsChart) ── */
|
||||
.progressScoresRow {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.progressScoresGrid {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.progressScoreItem {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.progressScoreLabel {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.progressScoreValue {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Admissions Q&A list ── */
|
||||
.admissionsQa {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admissionsQaRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.85rem 0;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admissionsQaRow:first-child {
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.admissionsQaRow:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.admissionsQaQuestion {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admissionsQaAnswer {
|
||||
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admissionsQaAnswerSub {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-left: 0.35rem;
|
||||
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
|
||||
}
|
||||
|
||||
.admissionsVerdict {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.admissionsVerdictHeadline {
|
||||
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
.admissionsVerdictOver {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
}
|
||||
|
||||
.admissionsVerdictUnder {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.admissionsVerdictSub {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admissionsQaRow {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 0.7rem 0;
|
||||
}
|
||||
|
||||
.admissionsQaAnswer {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── History accordion ── */
|
||||
.historyDisclosure {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.historyToggle {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #6d685f);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.historyToggle::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historyToggle::before {
|
||||
content: "▸";
|
||||
display: inline-block;
|
||||
transition: transform 0.2s ease;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.historyDisclosure[open] > .historyToggle::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { PerformanceChart } from './PerformanceChart';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { MetricTooltip } from './MetricTooltip';
|
||||
import type {
|
||||
@@ -22,6 +22,13 @@ import {
|
||||
buildOfstedHeroChip,
|
||||
} from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
|
||||
const PerformanceChart = dynamic(
|
||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
const SatsChart = dynamic(() => import('./SatsChart'), { ssr: false });
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SchoolDetailView.module.css';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
@@ -74,6 +81,29 @@ export function SchoolDetailView({
|
||||
const isInComparison = isSelected(schoolInfo.urn);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<string>('');
|
||||
const sectionNavRef = useRef<HTMLElement | null>(null);
|
||||
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionNavRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const overflow = el.scrollWidth - el.clientWidth;
|
||||
// No overflow → treat as "at end" so the fade is hidden.
|
||||
if (overflow <= 1) {
|
||||
setSectionNavAtEnd(true);
|
||||
return;
|
||||
}
|
||||
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
|
||||
};
|
||||
update();
|
||||
el.addEventListener('scroll', update, { passive: true });
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', update);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
|
||||
@@ -97,22 +127,43 @@ export function SchoolDetailView({
|
||||
const handleComparisonToggle = () => {
|
||||
if (isInComparison) {
|
||||
removeSchool(schoolInfo.urn);
|
||||
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||
} else {
|
||||
addSchool(schoolInfo);
|
||||
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||||
}
|
||||
};
|
||||
|
||||
// Page-view event with funnel attribution. Fires once per mount.
|
||||
useEffect(() => {
|
||||
track('school_viewed', {
|
||||
urn: schoolInfo.urn,
|
||||
phase: phase || 'unknown',
|
||||
local_authority: schoolInfo.local_authority || 'unknown',
|
||||
from: getNavigationSource(),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schoolInfo.urn]);
|
||||
|
||||
const deprivationDesc = (decile: number) => {
|
||||
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
||||
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
||||
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
||||
};
|
||||
|
||||
// Gender split availability (only meaningful for Mixed schools with census data)
|
||||
const isMixedSchool = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
||||
const hasGenderSplit = isMixedSchool
|
||||
&& census?.female_pupils != null
|
||||
&& census?.male_pupils != null
|
||||
&& (census.female_pupils + census.male_pupils) > 0;
|
||||
|
||||
// Guard for Pupils & Inclusion — only show if at least one metric is available
|
||||
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|
||||
|| (latestResults?.eal_pct != null)
|
||||
|| (latestResults?.sen_support_pct != null)
|
||||
|| senDetail != null;
|
||||
|| senDetail != null
|
||||
|| hasGenderSplit;
|
||||
|
||||
const hasSchoolLife = absenceData != null || census?.class_size_avg != null;
|
||||
const hasPhonics = phonics != null && phonics.year1_phonics_pct != null;
|
||||
@@ -229,17 +280,27 @@ export function SchoolDetailView({
|
||||
)}
|
||||
{schoolInfo.website && (
|
||||
<span className={styles.headerDetail}>
|
||||
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="school_website"
|
||||
>
|
||||
School website ↗
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{latestResults?.total_pupils != null && (
|
||||
{(() => {
|
||||
const total = census?.total_pupils ?? latestResults?.total_pupils ?? null;
|
||||
if (total == null) return null;
|
||||
return (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Pupils:</strong> {latestResults.total_pupils.toLocaleString()}
|
||||
<strong>Pupils:</strong> {total.toLocaleString()}
|
||||
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
||||
</span>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
{schoolInfo.trust_name && (
|
||||
<span className={styles.headerDetail}>
|
||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||
@@ -259,7 +320,10 @@ export function SchoolDetailView({
|
||||
|
||||
{/* Hero signal chip strip */}
|
||||
<div className={styles.heroChips}>
|
||||
<div className={`${styles.heroChip} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
|
||||
<div
|
||||
className={`${styles.heroChip} ${styles[`tone-${ofstedHeroChip.tone}`]}`}
|
||||
data-ofsted-state={ofstedHeroChip.state}
|
||||
>
|
||||
<div className={styles.heroChipTitle}>{ofstedHeroChip.title}</div>
|
||||
<div className={styles.heroChipSub}>{ofstedHeroChip.subtitle}</div>
|
||||
{ofstedHeroChip.detail && (
|
||||
@@ -343,7 +407,11 @@ export function SchoolDetailView({
|
||||
</header>
|
||||
|
||||
{/* Sticky Section Navigation */}
|
||||
<nav className={styles.sectionNav} aria-label="Page sections">
|
||||
<nav
|
||||
ref={sectionNavRef}
|
||||
className={`${styles.sectionNav}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
|
||||
aria-label="Page sections"
|
||||
>
|
||||
<div className={styles.sectionNavInner}>
|
||||
<button onClick={() => router.back()} className={styles.sectionNavBack}>← Back</button>
|
||||
{navItems.length > 0 && <div className={styles.sectionNavDivider} />}
|
||||
@@ -352,6 +420,7 @@ export function SchoolDetailView({
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
||||
onClick={() => track('section_nav_used', { section: id })}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
@@ -374,6 +443,8 @@ export function SchoolDetailView({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.ofstedReportLink}
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="ofsted"
|
||||
>
|
||||
Full report ↗
|
||||
</a>
|
||||
@@ -514,14 +585,14 @@ export function SchoolDetailView({
|
||||
{/* ── Primary / KS2 content ── */}
|
||||
{hasKS2Results && (
|
||||
<>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults.rwm_expected_pct !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Reading, Writing & Maths combined
|
||||
<MetricTooltip metricKey="rwm_expected_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.rwm_expected_pct)}
|
||||
{primaryAvg.rwm_expected_pct != null && (
|
||||
<DeltaChip
|
||||
@@ -533,17 +604,17 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.rwm_expected_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.rwm_high_pct !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Exceeding expected level (Reading, Writing & Maths)
|
||||
<MetricTooltip metricKey="rwm_high_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.rwm_high_pct)}
|
||||
{primaryAvg.rwm_high_pct != null && (
|
||||
<DeltaChip
|
||||
@@ -555,133 +626,91 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.rwm_high_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.metricGroupsGrid} style={{ marginTop: '1rem' }}>
|
||||
<div className={styles.metricGroup}>
|
||||
<h3 className={styles.metricGroupTitle}>Reading</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.reading_expected_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Expected level</span>
|
||||
<span className={styles.metricValue}>
|
||||
{formatPercentage(latestResults.reading_expected_pct)}
|
||||
{primaryAvg.reading_expected_pct != null && (
|
||||
<DeltaChip value={latestResults.reading_expected_pct} baseline={primaryAvg.reading_expected_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</span>
|
||||
{latestResults.rwm_expected_pct != null &&
|
||||
latestResults.reading_expected_pct != null &&
|
||||
latestResults.writing_expected_pct != null &&
|
||||
latestResults.maths_expected_pct != null && (
|
||||
<div className={styles.rwmBridge}>
|
||||
<span className={styles.rwmBridgeIcon} aria-hidden="true">?</span>
|
||||
<div className={styles.rwmBridgeBody}>
|
||||
<div className={styles.rwmBridgeText}>
|
||||
Why is combined lower? A pupil is only counted if they met the bar in{' '}
|
||||
<strong>all three</strong> subjects. Some passed reading but not writing; some passed writing but not maths.
|
||||
</div>
|
||||
<div className={styles.rwmBridgeMath}>
|
||||
<span>Reading <strong>{latestResults.reading_expected_pct.toFixed(0)}%</strong></span>
|
||||
<span className={styles.rwmBridgeMathSep}>·</span>
|
||||
<span>Writing <strong>{latestResults.writing_expected_pct.toFixed(0)}%</strong></span>
|
||||
<span className={styles.rwmBridgeMathSep}>·</span>
|
||||
<span>Maths <strong>{latestResults.maths_expected_pct.toFixed(0)}%</strong></span>
|
||||
<span className={styles.rwmBridgeMathSep}>→</span>
|
||||
<span>All three <strong>{latestResults.rwm_expected_pct.toFixed(0)}%</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.reading_high_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Exceeding</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_high_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.reading_progress !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
Progress score
|
||||
<MetricTooltip metricKey="reading_progress" />
|
||||
</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(latestResults.reading_progress)}`}>
|
||||
|
||||
<SatsChart
|
||||
subjects={[
|
||||
{
|
||||
name: 'Reading',
|
||||
expectedPct: latestResults.reading_expected_pct,
|
||||
exceedingPct: latestResults.reading_high_pct,
|
||||
nationalExpectedPct: primaryAvg.reading_expected_pct,
|
||||
},
|
||||
{
|
||||
name: 'Writing',
|
||||
expectedPct: latestResults.writing_expected_pct,
|
||||
exceedingPct: latestResults.writing_high_pct,
|
||||
nationalExpectedPct: primaryAvg.writing_expected_pct,
|
||||
},
|
||||
{
|
||||
name: 'Maths',
|
||||
expectedPct: latestResults.maths_expected_pct,
|
||||
exceedingPct: latestResults.maths_high_pct,
|
||||
nationalExpectedPct: primaryAvg.maths_expected_pct,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Progress scores row */}
|
||||
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
||||
<div className={styles.progressScoresRow}>
|
||||
<h3 className={styles.subSectionTitle}>Progress Scores</h3>
|
||||
<div className={styles.progressScoresGrid}>
|
||||
{latestResults.reading_progress != null && (
|
||||
<div className={styles.progressScoreItem}>
|
||||
<span className={styles.progressScoreLabel}>Reading</span>
|
||||
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.reading_progress)}`}>
|
||||
{formatProgress(latestResults.reading_progress)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.reading_avg_score !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
Average score
|
||||
<MetricTooltip metricKey="reading_avg_score" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.metricGroup}>
|
||||
<h3 className={styles.metricGroupTitle}>Writing</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.writing_expected_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Expected level</span>
|
||||
<span className={styles.metricValue}>
|
||||
{formatPercentage(latestResults.writing_expected_pct)}
|
||||
{primaryAvg.writing_expected_pct != null && (
|
||||
<DeltaChip value={latestResults.writing_expected_pct} baseline={primaryAvg.writing_expected_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.writing_high_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Exceeding</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_high_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.writing_progress !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
Progress score
|
||||
<MetricTooltip metricKey="writing_progress" />
|
||||
</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(latestResults.writing_progress)}`}>
|
||||
{latestResults.writing_progress != null && (
|
||||
<div className={styles.progressScoreItem}>
|
||||
<span className={styles.progressScoreLabel}>Writing</span>
|
||||
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.writing_progress)}`}>
|
||||
{formatProgress(latestResults.writing_progress)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.metricGroup}>
|
||||
<h3 className={styles.metricGroupTitle}>Maths</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.maths_expected_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Expected level</span>
|
||||
<span className={styles.metricValue}>
|
||||
{formatPercentage(latestResults.maths_expected_pct)}
|
||||
{primaryAvg.maths_expected_pct != null && (
|
||||
<DeltaChip value={latestResults.maths_expected_pct} baseline={primaryAvg.maths_expected_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.maths_high_pct !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Exceeding</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_high_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.maths_progress !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
Progress score
|
||||
<MetricTooltip metricKey="maths_progress" />
|
||||
</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(latestResults.maths_progress)}`}>
|
||||
{latestResults.maths_progress != null && (
|
||||
<div className={styles.progressScoreItem}>
|
||||
<span className={styles.progressScoreLabel}>Maths</span>
|
||||
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.maths_progress)}`}>
|
||||
{formatProgress(latestResults.maths_progress)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.maths_avg_score !== null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>
|
||||
Average score
|
||||
<MetricTooltip metricKey="maths_avg_score" />
|
||||
</span>
|
||||
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
|
||||
<p className={styles.progressNote}>
|
||||
@@ -852,33 +881,59 @@ export function SchoolDetailView({
|
||||
{admissions && (
|
||||
<section id="admissions" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({formatAcademicYear(admissions.year)})</h2>
|
||||
|
||||
{admissions.oversubscribed != null && (
|
||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||
{admissions.oversubscribed
|
||||
? '⚠ Oversubscribed'
|
||||
: '✓ Not Oversubscribed'}
|
||||
<div className={styles.admissionsVerdict}>
|
||||
<div className={styles.admissionsVerdictHeadline}>
|
||||
This school is{' '}
|
||||
<span className={admissions.oversubscribed ? styles.admissionsVerdictOver : styles.admissionsVerdictUnder}>
|
||||
{admissions.oversubscribed ? 'oversubscribed' : 'not oversubscribed'}
|
||||
</span>
|
||||
.
|
||||
</div>
|
||||
<div className={styles.admissionsVerdictSub}>
|
||||
{admissions.oversubscribed ? 'Demand exceeds capacity.' : 'Supply meets demand.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.metricsGrid}>
|
||||
{admissions.published_admission_number != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{isSecondary ? 'Year 7' : 'Reception'} places per year</div>
|
||||
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
|
||||
|
||||
<dl className={styles.admissionsQa}>
|
||||
{admissions.places_offered != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many places were offered?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>{admissions.places_offered}</dd>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Applications received</div>
|
||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||
{admissions.first_preference_applications != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many families wanted this school first?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>{admissions.first_preference_applications}</dd>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Families who got their first-choice</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many got their first choice?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>
|
||||
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
||||
<>
|
||||
{admissions.first_preference_offers}
|
||||
<span className={styles.admissionsQaAnswerSub}>
|
||||
of {admissions.first_preference_applications} ({formatPercentage(admissions.first_preference_offer_pct)})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
formatPercentage(admissions.first_preference_offer_pct)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many applied in total?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>{admissions.total_applications.toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -886,45 +941,82 @@ export function SchoolDetailView({
|
||||
{hasInclusionData && (
|
||||
<section id="inclusion" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Pupils & Inclusion</h2>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults?.disadvantaged_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Eligible for pupil premium</div>
|
||||
<div className={styles.metricValue}>
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>Eligible for pupil premium</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.disadvantaged_pct)}
|
||||
{primaryAvg.disadvantaged_pct != null && (
|
||||
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.metricHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
||||
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.eal_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
English as an additional language
|
||||
<MetricTooltip metricKey="eal_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.eal_pct)}</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.eal_pct)}
|
||||
{primaryAvg.eal_pct != null && (
|
||||
<DeltaChip value={latestResults.eal_pct} baseline={primaryAvg.eal_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.eal_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.sen_support_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Pupils receiving SEN support
|
||||
<MetricTooltip metricKey="sen_support_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.sen_support_pct)}
|
||||
{primaryAvg.sen_support_pct != null && (
|
||||
<DeltaChip value={latestResults.sen_support_pct} baseline={primaryAvg.sen_support_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.sen_support_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasGenderSplit && (() => {
|
||||
const female = census!.female_pupils!;
|
||||
const male = census!.male_pupils!;
|
||||
const girlsPct = Math.round((female / (female + male)) * 100);
|
||||
const boysPct = 100 - girlsPct;
|
||||
return (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>Boys and girls</div>
|
||||
<div className={styles.genderSplitValue}>
|
||||
<span className={styles.genderSplitGirls}>{girlsPct}%</span>
|
||||
<span className={styles.genderSplitLabel}>girls</span>
|
||||
<span className={styles.genderSplitSep}>·</span>
|
||||
<span className={styles.genderSplitBoys}>{boysPct}%</span>
|
||||
<span className={styles.genderSplitLabel}>boys</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.genderBar}
|
||||
role="img"
|
||||
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
|
||||
>
|
||||
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
|
||||
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
|
||||
</div>
|
||||
<div className={styles.heroStatHint}>
|
||||
{female.toLocaleString()} girls, {male.toLocaleString()} boys
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{senDetail && (
|
||||
<>
|
||||
@@ -1034,8 +1126,8 @@ export function SchoolDetailView({
|
||||
/>
|
||||
</div>
|
||||
{yearlyData.length > 1 && (
|
||||
<>
|
||||
<p className={styles.historicalSubtitle}>Detailed year-by-year figures</p>
|
||||
<details className={styles.historyDisclosure}>
|
||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.dataTable}>
|
||||
<thead>
|
||||
@@ -1084,7 +1176,7 @@ export function SchoolDetailView({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
.mapWrapper.fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.fullscreenBtn {
|
||||
|
||||
@@ -27,9 +27,11 @@ interface SchoolMapProps {
|
||||
zoom?: number;
|
||||
referencePoint?: [number, number];
|
||||
onMarkerClick?: (school: School) => void;
|
||||
nationalAvgRwm?: number | null;
|
||||
laAverages?: Record<string, number | null>;
|
||||
}
|
||||
|
||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick }: SchoolMapProps) {
|
||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
@@ -89,6 +91,8 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
||||
zoom={zoom}
|
||||
referencePoint={referencePoint}
|
||||
onMarkerClick={onMarkerClick}
|
||||
nationalAvgRwm={nationalAvgRwm}
|
||||
laAverages={laAverages}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -199,6 +199,16 @@
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
|
||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||
/* ofsted1–4 already defined above; these cover the two new framework states */
|
||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
||||
|
||||
/* ── vs-national delta line (under RWM metric) ──────────────────────────── */
|
||||
.vsNational { font-size: 0.7rem; color: var(--accent-teal, #2d7d7d); font-weight: 600; }
|
||||
.vsNationalNeg { font-size: 0.7rem; color: var(--accent-coral, #e07256); font-weight: 600; }
|
||||
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted, #8a847a); }
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.row {
|
||||
@@ -215,8 +225,14 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Promote the headline metric onto its own line; secondary stats
|
||||
(delta vs LA, pupils) wrap below with a visible row gap. */
|
||||
.line3 {
|
||||
gap: 0 1rem;
|
||||
row-gap: 0.25rem;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
.line3 > .stat:first-child {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
|
||||
@@ -2,30 +2,23 @@
|
||||
* SchoolRow Component
|
||||
* Four-line row for primary school search results
|
||||
*
|
||||
* Line 1: School name · Ofsted badge
|
||||
* Line 1: School name · Ofsted badge (framework-aware)
|
||||
* Line 2: School type · Age range · Denomination · Gender
|
||||
* Line 3: R,W&M % · Progress score · Pupil count
|
||||
* Line 3: Reading, Writing & Maths % · trend arrow · vs-national delta · Pupils
|
||||
* Line 4: Local authority · Distance
|
||||
*/
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, calculateTrend, getPhaseStyle, schoolUrl } from '@/lib/utils';
|
||||
import { progressBand } from '@/lib/metrics';
|
||||
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
||||
import styles from './SchoolRow.module.css';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
1: 'Outstanding',
|
||||
2: 'Good',
|
||||
3: 'Req. Improvement',
|
||||
4: 'Inadequate',
|
||||
};
|
||||
|
||||
interface SchoolRowProps {
|
||||
school: School;
|
||||
isLocationSearch?: boolean;
|
||||
isInCompare?: boolean;
|
||||
onAddToCompare?: (school: School) => void;
|
||||
onRemoveFromCompare?: (urn: number) => void;
|
||||
nationalAvgRwm?: number | null;
|
||||
}
|
||||
|
||||
export function SchoolRow({
|
||||
@@ -34,13 +27,22 @@ export function SchoolRow({
|
||||
isInCompare = false,
|
||||
onAddToCompare,
|
||||
onRemoveFromCompare,
|
||||
nationalAvgRwm,
|
||||
}: SchoolRowProps) {
|
||||
const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct);
|
||||
const phase = getPhaseStyle(school.phase);
|
||||
const ofstedBadge = buildOfstedListBadge(school);
|
||||
|
||||
// Use reading progress as representative; fall back to writing, then maths
|
||||
const progressScore =
|
||||
school.reading_progress ?? school.writing_progress ?? school.maths_progress ?? null;
|
||||
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
|
||||
const showDenomination =
|
||||
school.religious_denomination &&
|
||||
school.religious_denomination !== 'Does not apply';
|
||||
|
||||
// vs-national delta
|
||||
const rwmDelta =
|
||||
school.rwm_expected_pct != null && nationalAvgRwm != null
|
||||
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
||||
: null;
|
||||
|
||||
const handleCompareClick = () => {
|
||||
if (isInCompare) {
|
||||
@@ -50,11 +52,6 @@ export function SchoolRow({
|
||||
}
|
||||
};
|
||||
|
||||
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
|
||||
const showDenomination =
|
||||
school.religious_denomination &&
|
||||
school.religious_denomination !== 'Does not apply';
|
||||
|
||||
return (
|
||||
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
|
||||
{/* Left: four content lines */}
|
||||
@@ -65,16 +62,9 @@ export function SchoolRow({
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.ofsted_grade && (
|
||||
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
||||
{OFSTED_LABELS[school.ofsted_grade]}
|
||||
{school.ofsted_date && (
|
||||
<span className={styles.ofstedDate}>
|
||||
{' '}({new Date(school.ofsted_date).getFullYear()})
|
||||
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
|
||||
{ofstedBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 2: Context tags */}
|
||||
@@ -92,10 +82,9 @@ export function SchoolRow({
|
||||
|
||||
{/* Line 3: Key stats */}
|
||||
<div className={styles.line3}>
|
||||
{school.rwm_expected_pct != null ? (
|
||||
<span className={styles.stat}>
|
||||
<strong className={styles.statValue}>
|
||||
{formatPercentage(school.rwm_expected_pct, 0)}
|
||||
{school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
|
||||
</strong>
|
||||
{school.prev_rwm_expected_pct != null && (
|
||||
<span
|
||||
@@ -119,23 +108,25 @@ export function SchoolRow({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.statLabel}>R, W & M</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.stat}>
|
||||
<strong className={styles.statValue}>—</strong>
|
||||
<span className={styles.statLabel}>R, W & M</span>
|
||||
<span className={styles.statLabel}>Reading, Writing & Maths</span>
|
||||
{rwmDelta != null && (
|
||||
<span
|
||||
className={
|
||||
rwmDelta >= 2
|
||||
? styles.vsNational
|
||||
: rwmDelta <= -2
|
||||
? styles.vsNationalNeg
|
||||
: styles.vsNationalFlat
|
||||
}
|
||||
>
|
||||
{rwmDelta >= 2
|
||||
? `+${rwmDelta} pts vs national`
|
||||
: rwmDelta <= -2
|
||||
? `${rwmDelta} pts vs national`
|
||||
: '≈ national avg'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{progressScore != null && (
|
||||
<span className={styles.stat}>
|
||||
<strong className={styles.statValue}>{formatProgress(progressScore)}</strong>
|
||||
<span className={styles.statLabel}>
|
||||
progress · {progressBand(progressScore)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{school.total_pupils != null && (
|
||||
<span className={styles.stat}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Modal } from "./Modal";
|
||||
import { useComparison } from "@/hooks/useComparison";
|
||||
import { debounce } from "@/lib/utils";
|
||||
import { fetchSchools } from "@/lib/api";
|
||||
import { track } from "@/lib/analytics";
|
||||
import type { School } from "@/lib/types";
|
||||
import styles from "./SchoolSearchModal.module.css";
|
||||
|
||||
@@ -60,6 +61,11 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
||||
|
||||
const handleAddSchool = (school: School) => {
|
||||
addSchool(school);
|
||||
track('compare_school_added', {
|
||||
urn: school.urn,
|
||||
from: 'compare',
|
||||
selection_count_after: selectedSchools.length + 1,
|
||||
});
|
||||
// Don't close modal, allow adding multiple schools
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.15;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
/* ── Tab Navigation (sticky) ─────────────────────────── */
|
||||
.tabNav {
|
||||
position: sticky;
|
||||
top: 3.5rem;
|
||||
top: 4rem;
|
||||
z-index: 10;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
@@ -237,7 +237,7 @@
|
||||
margin-bottom: 0.875rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
@@ -247,7 +247,7 @@
|
||||
}
|
||||
|
||||
.sectionTitle::before {
|
||||
content: '';
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
@@ -469,16 +469,43 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ofstedGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofstedGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||
.ofstedGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||
.ofstedGrade4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
.ofstedGrade1 {
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
.ofstedGrade2 {
|
||||
background: rgba(60, 140, 60, 0.12);
|
||||
color: #3c8c3c;
|
||||
}
|
||||
.ofstedGrade3 {
|
||||
background: var(--accent-gold-bg);
|
||||
color: #b8920e;
|
||||
}
|
||||
.ofstedGrade4 {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.rcGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.rcGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||
.rcGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||
.rcGrade4 { background: rgba(249, 115, 22, 0.12); color: #c2410c; }
|
||||
.rcGrade5 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
.rcGrade1 {
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
.rcGrade2 {
|
||||
background: rgba(60, 140, 60, 0.12);
|
||||
color: #3c8c3c;
|
||||
}
|
||||
.rcGrade3 {
|
||||
background: var(--accent-gold-bg);
|
||||
color: #b8920e;
|
||||
}
|
||||
.rcGrade4 {
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
color: #c2410c;
|
||||
}
|
||||
.rcGrade5 {
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.safeguardingMet {
|
||||
display: inline-block;
|
||||
@@ -718,11 +745,21 @@
|
||||
}
|
||||
|
||||
/* Hero tone system */
|
||||
.tone-teal { --hero-tone: var(--accent-teal, #2d7d7d); }
|
||||
.tone-green { --hero-tone: #3c8c3c; }
|
||||
.tone-gold { --hero-tone: var(--accent-gold, #c9a227); }
|
||||
.tone-coral { --hero-tone: var(--accent-coral, #e07256); }
|
||||
.tone-neutral { --hero-tone: var(--text-muted, #8a847a); }
|
||||
.tone-teal {
|
||||
--hero-tone: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
.tone-green {
|
||||
--hero-tone: #3c8c3c;
|
||||
}
|
||||
.tone-gold {
|
||||
--hero-tone: var(--accent-gold, #c9a227);
|
||||
}
|
||||
.tone-coral {
|
||||
--hero-tone: var(--accent-coral, #e07256);
|
||||
}
|
||||
.tone-neutral {
|
||||
--hero-tone: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.heroChip.tone-teal,
|
||||
.heroChip.tone-green,
|
||||
@@ -757,7 +794,7 @@
|
||||
|
||||
.heroStatNumber,
|
||||
.heroStatNumberSerif {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary, #1a1612);
|
||||
@@ -802,6 +839,248 @@
|
||||
color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
/* ── GCSE hero stat cards (mirrors primary heroStatCard) ─ */
|
||||
.heroStatGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.heroStatCard {
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
border: 1px solid rgba(45, 125, 125, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.heroStatCard .heroStatLabel {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
.heroStatCard .heroStatValue {
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
font-size: 2.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.heroStatCard .heroStatHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
font-style: normal;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Gender split — attached beneath the Total pupils stat value */
|
||||
.genderBar {
|
||||
display: flex;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.genderBarGirls {
|
||||
background: #b45778;
|
||||
}
|
||||
|
||||
.genderBarBoys {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
.genderSplitHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-top: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.genderSplitGirls {
|
||||
color: #b45778;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.genderSplitBoys {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.genderSplitSep {
|
||||
color: var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
/* ── Attainment 8 visual bar ─────────────────────────── */
|
||||
.att8Viz {
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.att8VizLabel {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.att8VizTrack {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
background: rgba(45, 125, 125, 0.08);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.att8VizFill {
|
||||
height: 100%;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
border-radius: 4px 0 0 4px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.att8VizNatLine {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
width: 2px;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border-radius: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.att8VizNatPill {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.att8VizTicks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
/* ── Progress 8 number line ──────────────────────────── */
|
||||
.p8Viz {
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.p8VizLabel {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-muted, #6d685f);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.p8VizTrack {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
background: rgba(45, 125, 125, 0.06);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.p8VizCi {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(45, 125, 125, 0.18);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.p8VizZero {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
width: 2px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.p8VizDot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.p8VizDotNeg {
|
||||
background: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.p8VizTicks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
}
|
||||
|
||||
/* ── History accordion ───────────────────────────────── */
|
||||
.historyDisclosure {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.historyToggle {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #6d685f);
|
||||
padding: 0.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.historyToggle::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historyToggle::before {
|
||||
content: "▶";
|
||||
font-size: 0.6rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.historyDisclosure[open] .historyToggle::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
@@ -848,6 +1127,14 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroStatGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heroStatCard .heroStatValue {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
@@ -8,10 +8,15 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { PerformanceChart } from './PerformanceChart';
|
||||
import { MetricTooltip } from './MetricTooltip';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
|
||||
const PerformanceChart = dynamic(
|
||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData,
|
||||
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||
@@ -20,6 +25,7 @@ import type {
|
||||
} from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SecondarySchoolDetailView.module.css';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
@@ -70,7 +76,7 @@ interface SecondarySchoolDetailViewProps {
|
||||
|
||||
export function SecondarySchoolDetailView({
|
||||
schoolInfo, yearlyData,
|
||||
ofsted, parentView, admissions, senDetail, deprivation, finance, absenceData,
|
||||
ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData,
|
||||
}: SecondarySchoolDetailViewProps) {
|
||||
const router = useRouter();
|
||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||
@@ -111,11 +117,23 @@ export function SecondarySchoolDetailView({
|
||||
const handleComparisonToggle = () => {
|
||||
if (isInComparison) {
|
||||
removeSchool(schoolInfo.urn);
|
||||
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||
} else {
|
||||
addSchool(schoolInfo);
|
||||
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
track('school_viewed', {
|
||||
urn: schoolInfo.urn,
|
||||
phase: schoolInfo.phase || 'secondary',
|
||||
local_authority: schoolInfo.local_authority || 'unknown',
|
||||
from: getNavigationSource(),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schoolInfo.urn]);
|
||||
|
||||
// Build nav items dynamically based on available data
|
||||
const navItems: { id: string; label: string }[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
@@ -210,7 +228,13 @@ export function SecondarySchoolDetailView({
|
||||
)}
|
||||
{schoolInfo.website && (
|
||||
<span className={styles.headerDetail}>
|
||||
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="school_website"
|
||||
>
|
||||
School website ↗
|
||||
</a>
|
||||
</span>
|
||||
@@ -318,6 +342,7 @@ export function SecondarySchoolDetailView({
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
||||
onClick={() => track('section_nav_used', { section: id })}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
@@ -340,6 +365,8 @@ export function SecondarySchoolDetailView({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.ofstedReportLink}
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="ofsted"
|
||||
>
|
||||
Full report ↗
|
||||
</a>
|
||||
@@ -494,61 +521,153 @@ export function SecondarySchoolDetailView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.metricsGrid}>
|
||||
{/* Hero stat cards — top GCSE metrics */}
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults.attainment_8_score != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Attainment 8
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Attainment 8 score
|
||||
<MetricTooltip metricKey="attainment_8_score" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{latestResults.attainment_8_score.toFixed(1)}
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
<DeltaChip
|
||||
value={latestResults.attainment_8_score}
|
||||
baseline={secondaryAvg.attainment_8_score}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.heroStatHint}>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
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Progress 8 score
|
||||
<MetricTooltip metricKey="progress_8_score" />
|
||||
</div>
|
||||
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
|
||||
<div className={`${styles.heroStatValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
|
||||
{formatProgress(latestResults.progress_8_score)}
|
||||
</div>
|
||||
{(latestResults.progress_8_lower_ci != null || latestResults.progress_8_upper_ci != null) && (
|
||||
<div className={styles.metricHint}>
|
||||
CI: {latestResults.progress_8_lower_ci?.toFixed(2) ?? '?'} to {latestResults.progress_8_upper_ci?.toFixed(2) ?? '?'}
|
||||
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
|
||||
<div className={styles.heroStatHint}>
|
||||
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
English & Maths Grade 4+
|
||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
) : (
|
||||
<div className={styles.heroStatHint}>National baseline: 0.0</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
English & Maths Grade 5+
|
||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
<DeltaChip
|
||||
value={latestResults.english_maths_strong_pass_pct}
|
||||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
English & Maths Grade 4+
|
||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.english_maths_standard_pass_pct}
|
||||
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attainment 8 visual bar (0–80 scale) */}
|
||||
{latestResults.attainment_8_score != null && (
|
||||
<div className={styles.att8Viz}>
|
||||
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
||||
<div className={styles.att8VizTrack}>
|
||||
<div
|
||||
className={styles.att8VizFill}
|
||||
style={{ width: `${Math.min((latestResults.attainment_8_score / 80) * 100, 100)}%` }}
|
||||
/>
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div
|
||||
className={styles.att8VizNatLine}
|
||||
style={{ left: `${(secondaryAvg.attainment_8_score / 80) * 100}%` }}
|
||||
>
|
||||
<div className={styles.att8VizNatPill}>
|
||||
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.att8VizTicks}>
|
||||
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress 8 number line with CI */}
|
||||
{latestResults.progress_8_score != null && !p8Suspended && (
|
||||
<div className={styles.p8Viz}>
|
||||
<div className={styles.p8VizLabel}>Progress 8 — relative to national baseline (0)</div>
|
||||
{(() => {
|
||||
const p8 = latestResults.progress_8_score!;
|
||||
const lo = latestResults.progress_8_lower_ci ?? p8;
|
||||
const hi = latestResults.progress_8_upper_ci ?? p8;
|
||||
const range = 6; // −3 to +3
|
||||
const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`;
|
||||
return (
|
||||
<div className={styles.p8VizTrack}>
|
||||
{/* CI band */}
|
||||
<div
|
||||
className={styles.p8VizCi}
|
||||
style={{ left: toX(lo), width: `calc(${toX(hi)} - ${toX(lo)})` }}
|
||||
/>
|
||||
{/* Zero line */}
|
||||
<div className={styles.p8VizZero} style={{ left: toX(0) }} />
|
||||
{/* Score dot */}
|
||||
<div
|
||||
className={`${styles.p8VizDot} ${p8 < 0 ? styles.p8VizDotNeg : ''}`}
|
||||
style={{ left: toX(p8) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className={styles.p8VizTicks}>
|
||||
<span>−3</span><span>−2</span><span>−1</span><span>0</span><span>+1</span><span>+2</span><span>+3</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress 8 component breakdown */}
|
||||
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
|
||||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
|
||||
@@ -608,21 +727,6 @@ export function SecondarySchoolDetailView({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Performance chart */}
|
||||
{yearlyData.length > 0 && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={true}
|
||||
nationalAtt8Avg={heroAtt8Nat}
|
||||
nationalByYear={nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -641,10 +745,10 @@ export function SecondarySchoolDetailView({
|
||||
)}
|
||||
|
||||
<div className={styles.metricsGrid}>
|
||||
{admissions.published_admission_number != null && (
|
||||
{admissions.places_offered != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Year 7 places per year (PAN)</div>
|
||||
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
|
||||
<div className={styles.metricLabel}>Year 7 places offered</div>
|
||||
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
@@ -695,36 +799,64 @@ export function SecondarySchoolDetailView({
|
||||
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults?.sen_support_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Pupils receiving SEN support
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
SEN support
|
||||
<MetricTooltip metricKey="sen_support_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
||||
<div className={styles.metricHint}>SEN support without an EHCP</div>
|
||||
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
||||
<div className={styles.heroStatHint}>Without an EHCP</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.sen_ehcp_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
Pupils with an EHCP
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Pupils with EHCP
|
||||
<MetricTooltip metricKey="sen_ehcp_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
||||
<div className={styles.metricHint}>Education, Health and Care Plan</div>
|
||||
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
||||
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
|
||||
</div>
|
||||
)}
|
||||
{(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total pupils</div>
|
||||
<div className={styles.metricValue}>{(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()}</div>
|
||||
{schoolInfo.capacity != null && (
|
||||
<div className={styles.metricHint}>Capacity: {schoolInfo.capacity}</div>
|
||||
{(() => {
|
||||
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
|
||||
if (total == null) return null;
|
||||
const female = census?.female_pupils ?? null;
|
||||
const male = census?.male_pupils ?? null;
|
||||
const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
||||
const hasSplit = isMixed && female != null && male != null && female + male > 0;
|
||||
const sum = hasSplit ? female! + male! : 0;
|
||||
const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0;
|
||||
const boysPct = hasSplit ? 100 - girlsPct : 0;
|
||||
return (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>Total pupils</div>
|
||||
<div className={styles.heroStatValue}>{total.toLocaleString()}</div>
|
||||
{hasSplit && (
|
||||
<>
|
||||
<div
|
||||
className={styles.genderBar}
|
||||
role="img"
|
||||
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
|
||||
>
|
||||
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
|
||||
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
|
||||
</div>
|
||||
<div className={styles.genderSplitHint}>
|
||||
<span className={styles.genderSplitGirls}>{girlsPct}% girls</span>
|
||||
<span className={styles.genderSplitSep}> · </span>
|
||||
<span className={styles.genderSplitBoys}>{boysPct}% boys</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{schoolInfo.capacity != null && !hasSplit && (
|
||||
<div className={styles.heroStatHint}>Capacity: {schoolInfo.capacity}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -808,6 +940,22 @@ export function SecondarySchoolDetailView({
|
||||
{yearlyData.length > 1 && (
|
||||
<section id="history" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Historical Results</h2>
|
||||
{yearlyData.length > 0 && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={true}
|
||||
nationalAtt8Avg={heroAtt8Nat}
|
||||
nationalByYear={nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<details className={styles.historyDisclosure}>
|
||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.dataTable}>
|
||||
<thead>
|
||||
@@ -832,6 +980,7 @@ export function SecondarySchoolDetailView({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -201,6 +201,10 @@
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
|
||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
||||
|
||||
/* ── Right actions column ────────────────────────────── */
|
||||
.rowActions {
|
||||
display: flex;
|
||||
@@ -232,8 +236,15 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Promote the headline metric (Attainment 8) onto its own line so
|
||||
the secondary stats — delta vs LA, pupils — wrap below it with
|
||||
visible row gap instead of crowding the same row. */
|
||||
.line3 {
|
||||
gap: 0 1rem;
|
||||
row-gap: 0.25rem;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
.line3 > .stat:first-child {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
|
||||
@@ -4,23 +4,16 @@
|
||||
*
|
||||
* Line 1: School name · Ofsted badge
|
||||
* Line 2: School type · Age range · Gender · Sixth form · Admissions tag
|
||||
* Line 3: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils
|
||||
* Line 3: Attainment 8 (large) · ±LA avg delta · Pupils
|
||||
* Line 4: LA name · distance
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { getPhaseStyle, schoolUrl } from '@/lib/utils';
|
||||
import { buildOfstedListBadge, getPhaseStyle, schoolUrl } from '@/lib/utils';
|
||||
import styles from './SecondarySchoolRow.module.css';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
1: 'Outstanding',
|
||||
2: 'Good',
|
||||
3: 'Req. Improvement',
|
||||
4: 'Inadequate',
|
||||
};
|
||||
|
||||
function detectAdmissionsTag(school: School): string | null {
|
||||
const policy = school.admissions_policy?.toLowerCase() ?? '';
|
||||
if (policy.includes('selective')) return 'Selective';
|
||||
@@ -58,8 +51,9 @@ export function SecondarySchoolRow({
|
||||
}
|
||||
};
|
||||
|
||||
const ofstedBadge = buildOfstedListBadge(school);
|
||||
const phase = getPhaseStyle(school.phase);
|
||||
const att8 = school.attainment_8_score ?? null;
|
||||
const att8 = school.attainment_8_score;
|
||||
const laDelta =
|
||||
att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
|
||||
|
||||
@@ -77,16 +71,9 @@ export function SecondarySchoolRow({
|
||||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.ofsted_grade && (
|
||||
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
||||
{OFSTED_LABELS[school.ofsted_grade]}
|
||||
{school.ofsted_date && (
|
||||
<span className={styles.ofstedDate}>
|
||||
{' '}({new Date(school.ofsted_date).getFullYear()})
|
||||
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
|
||||
{ofstedBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 2: Context tags */}
|
||||
@@ -126,15 +113,6 @@ export function SecondarySchoolRow({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{school.english_maths_standard_pass_pct != null && (
|
||||
<span className={styles.stat}>
|
||||
<strong className={styles.statValue}>
|
||||
{school.english_maths_standard_pass_pct.toFixed(0)}%
|
||||
</strong>
|
||||
<span className={styles.statLabel}>Eng & Maths 4+</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{school.total_pupils != null && (
|
||||
<span className={styles.stat}>
|
||||
<strong className={styles.statValue}>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Analytics tracking for Umami.
|
||||
*
|
||||
* Single typed wrapper around `window.umami.track()`. All events flow
|
||||
* through `track(name, data?)` — this gives us:
|
||||
* - Refactor-safe event names (one place to maintain).
|
||||
* - A schema for properties so we don't ship typos that fragment dashboards.
|
||||
* - No-op on the server and never-throws semantics, so analytics outages
|
||||
* can't take the app down.
|
||||
*
|
||||
* Umami is privacy-friendly (no cookies, no IPs, no PII), so it's safe to
|
||||
* include school identifiers and full search query text.
|
||||
*/
|
||||
|
||||
export type EventName =
|
||||
// Discovery
|
||||
| 'search_submitted'
|
||||
| 'near_me_used'
|
||||
| 'empty_results'
|
||||
// Engagement
|
||||
| 'school_viewed'
|
||||
| 'section_nav_used'
|
||||
| 'chart_metric_changed'
|
||||
| 'metric_compared_in_rankings'
|
||||
| 'external_link_clicked'
|
||||
// Conversion
|
||||
| 'compare_school_added'
|
||||
| 'compare_school_removed'
|
||||
| 'compare_viewed'
|
||||
| 'compare_metric_changed'
|
||||
| 'compare_shared'
|
||||
// Operational
|
||||
| 'api_error'
|
||||
| 'results_load_more';
|
||||
|
||||
type Primitive = string | number | boolean;
|
||||
type Payload = Record<string, Primitive>;
|
||||
|
||||
/**
|
||||
* Fire an event. No-ops if Umami isn't loaded yet (the script is `defer`)
|
||||
* or if we're rendering server-side. Never throws.
|
||||
*/
|
||||
export function track(name: EventName, data?: Payload): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const umami = (window as unknown as { umami?: { track?: (n: string, d?: Payload) => void } }).umami;
|
||||
if (!umami?.track) return;
|
||||
try {
|
||||
umami.track(name, data);
|
||||
} catch {
|
||||
// Analytics must never crash the app.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorise where the user navigated from, for funnel attribution
|
||||
* (mostly used on school_viewed). Only checks same-origin referrers.
|
||||
*/
|
||||
export function getNavigationSource(): 'search' | 'rankings' | 'compare' | 'detail' | 'direct' {
|
||||
if (typeof window === 'undefined' || !document.referrer) return 'direct';
|
||||
try {
|
||||
const ref = new URL(document.referrer);
|
||||
if (ref.origin !== window.location.origin) return 'direct';
|
||||
const p = ref.pathname;
|
||||
if (p === '/' || p === '') return 'search';
|
||||
if (p.startsWith('/rankings')) return 'rankings';
|
||||
if (p.startsWith('/compare')) return 'compare';
|
||||
if (p.startsWith('/school/')) return 'detail';
|
||||
return 'direct';
|
||||
} catch {
|
||||
return 'direct';
|
||||
}
|
||||
}
|
||||
|
||||
/** Split mobile vs desktop on a per-event basis. */
|
||||
export function getViewport(): 'mobile' | 'desktop' {
|
||||
if (typeof window === 'undefined') return 'desktop';
|
||||
return window.matchMedia('(max-width: 640px)').matches ? 'mobile' : 'desktop';
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
RankingsParams,
|
||||
APIError,
|
||||
LAaveragesResponse,
|
||||
NationalAverages,
|
||||
} from './types';
|
||||
|
||||
// ============================================================================
|
||||
@@ -64,6 +65,16 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
||||
// If parsing JSON fails, use the default error
|
||||
}
|
||||
|
||||
// Client-side: report to analytics so we can spot silent failures.
|
||||
// No-ops on SSR (track guards against missing window).
|
||||
if (typeof window !== 'undefined') {
|
||||
const { track } = await import('./analytics');
|
||||
try {
|
||||
const endpoint = new URL(response.url).pathname;
|
||||
track('api_error', { endpoint, status: response.status, route: window.location.pathname });
|
||||
} catch { /* never */ }
|
||||
}
|
||||
|
||||
throw new APIFetchError(
|
||||
`API request failed: ${errorDetail}`,
|
||||
response.status,
|
||||
@@ -261,6 +272,26 @@ export async function fetchLAaverages(
|
||||
return handleResponse<LAaveragesResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch official DfE KS2 national averages (primary) and computed KS4 secondary averages.
|
||||
* Returns latest year snapshot plus per-year history for chart reference lines.
|
||||
*/
|
||||
export async function fetchNationalAverages(
|
||||
options: RequestInit = {}
|
||||
): Promise<NationalAverages> {
|
||||
const url = `${API_BASE_URL}/national-averages`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: 3600,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<NationalAverages>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch database statistics and info
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Single Chart.js registration point.
|
||||
* Import this module (side-effect import) in any file that uses react-chartjs-2.
|
||||
* Chart.js is idempotent about duplicate registrations, but importing the
|
||||
* registration from one place ensures every primitive is only bundled once
|
||||
* and the register() call is not duplicated at runtime.
|
||||
*/
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
);
|
||||
+14
-7
@@ -67,6 +67,7 @@ export interface School {
|
||||
// Ofsted (for list view — summary only)
|
||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||
ofsted_date?: string | null;
|
||||
ofsted_framework?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -119,18 +120,24 @@ export interface OfstedParentView {
|
||||
|
||||
export interface SchoolCensus {
|
||||
year: number;
|
||||
class_size_avg: number | null;
|
||||
ethnicity_white_pct: number | null;
|
||||
ethnicity_asian_pct: number | null;
|
||||
ethnicity_black_pct: number | null;
|
||||
ethnicity_mixed_pct: number | null;
|
||||
ethnicity_other_pct: number | null;
|
||||
total_pupils: number | null;
|
||||
female_pupils: number | null;
|
||||
male_pupils: number | null;
|
||||
fsm_pct: number | null;
|
||||
eal_pct: number | null;
|
||||
class_size_avg?: number | null;
|
||||
ethnicity_white_pct?: number | null;
|
||||
ethnicity_asian_pct?: number | null;
|
||||
ethnicity_black_pct?: number | null;
|
||||
ethnicity_mixed_pct?: number | null;
|
||||
ethnicity_other_pct?: number | null;
|
||||
}
|
||||
|
||||
export interface SchoolAdmissions {
|
||||
year: number;
|
||||
school_phase?: string | null;
|
||||
published_admission_number: number | null;
|
||||
/** Number of places the school offered in this admissions round (not PAN — EES doesn't expose PAN). */
|
||||
places_offered: number | null;
|
||||
total_applications: number | null;
|
||||
first_preference_applications?: number | null;
|
||||
first_preference_offers?: number | null;
|
||||
|
||||
@@ -71,6 +71,13 @@ export function formatPercentage(value: number | null | undefined, decimals: num
|
||||
return `${value.toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
export function formatWithSuppression(value: number | null | undefined): { display: string; suppressed: boolean } {
|
||||
if (value == null) return { display: '—', suppressed: true };
|
||||
return { display: formatPercentage(value), suppressed: false };
|
||||
}
|
||||
|
||||
export const SUPPRESSED_TOOLTIP = 'Data not available — may be suppressed to protect small cohorts.';
|
||||
|
||||
/**
|
||||
* Format a progress score (can be negative)
|
||||
*/
|
||||
@@ -570,3 +577,49 @@ export function buildSchoolSummary(
|
||||
|
||||
return parts.join(', ') + '.';
|
||||
}
|
||||
|
||||
// ─── List-level Ofsted badge ──────────────────────────────────────────────────
|
||||
|
||||
export interface OfstedListBadge {
|
||||
/** Display text for the badge (e.g. "Outstanding · 2023", "Report Card · 2025") */
|
||||
label: string;
|
||||
/** CSS module class key — one of: ofsted1 | ofsted2 | ofsted3 | ofsted4 | ofstedRc | ofstedPending */
|
||||
cssClass: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Ofsted badge for a school card in the list/map view.
|
||||
* Three states:
|
||||
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
|
||||
* - ReportCard school (ofsted_framework === 'ReportCard'): "Report Card · YYYY" in purple
|
||||
* - No inspection: "Not yet inspected" in grey
|
||||
*/
|
||||
export function buildOfstedListBadge(school: {
|
||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||
ofsted_date?: string | null;
|
||||
ofsted_framework?: string | null;
|
||||
}): OfstedListBadge {
|
||||
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',
|
||||
};
|
||||
return {
|
||||
label: `${labels[school.ofsted_grade]}${yearStr}`,
|
||||
cssClass: `ofsted${school.ofsted_grade}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (school.ofsted_framework === 'ReportCard') {
|
||||
return { label: `Report Card${yearStr}`, cssClass: 'ofstedRc' };
|
||||
}
|
||||
|
||||
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const nextConfig = {
|
||||
{ protocol: 'https', hostname: 'cdnjs.cloudflare.com' },
|
||||
],
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
minimumCacheTTL: 60,
|
||||
minimumCacheTTL: 31536000,
|
||||
},
|
||||
|
||||
// Performance optimizations
|
||||
|
||||
Generated
+11
-22
@@ -90,7 +90,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -641,7 +640,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -665,7 +663,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2295,6 +2292,7 @@
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
@@ -2383,7 +2381,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -2564,7 +2563,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2574,7 +2572,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -2652,7 +2649,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz",
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -3128,7 +3124,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3596,7 +3591,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3745,7 +3739,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -4145,7 +4138,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
@@ -4411,7 +4405,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4589,7 +4582,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -7137,7 +7129,6 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -7267,8 +7258,7 @@
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
@@ -7348,6 +7338,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -8094,6 +8085,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -8109,6 +8101,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -8121,7 +8114,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
@@ -8185,7 +8179,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -8205,7 +8198,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -9219,7 +9211,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9448,7 +9439,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -10023,7 +10013,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "SchoolCompare",
|
||||
"short_name": "SchoolCompare",
|
||||
"description": "Compare primary school KS2 performance across England",
|
||||
"description": "Compare primary and secondary school performance across England",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#3b82f6",
|
||||
"background_color": "#faf7f2",
|
||||
"theme_color": "#faf7f2",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
|
||||
@@ -141,7 +141,7 @@ with DAG(
|
||||
|
||||
dbt_build_ees = BashOperator(
|
||||
task_id="dbt_build",
|
||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+",
|
||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+",
|
||||
)
|
||||
|
||||
sync_typesense_ees = BashOperator(
|
||||
|
||||
+12
-4
@@ -26,12 +26,20 @@ plugins:
|
||||
- name: legacy_ks2_urls
|
||||
kind: object
|
||||
description: "Year code → URL mapping for legacy KS2 CSVs"
|
||||
- name: legacy_ks4_urls
|
||||
kind: object
|
||||
description: "Year code → URL mapping for legacy KS4 ZIPs (england_ks4final.csv inside)"
|
||||
config:
|
||||
legacy_ks2_urls:
|
||||
"201516": "http://10.0.1.224:8081/filebrowser/api/public/dl/R9jjXFWa?inline=true"
|
||||
"201617": "http://10.0.1.224:8081/filebrowser/api/public/dl/tIwJPVQS?inline=true"
|
||||
"201718": "http://10.0.1.224:8081/filebrowser/api/public/dl/GO7SKE0p?inline=true"
|
||||
"201819": "http://10.0.1.224:8081/filebrowser/api/public/dl/jchDEHsv?inline=true"
|
||||
"201516": "http://10.0.1.224:8081/filebrowser/api/public/dl/iaoSkg1v?inline=true"
|
||||
"201617": "http://10.0.1.224:8081/filebrowser/api/public/dl/bqCMUcIH?inline=true"
|
||||
"201718": "http://10.0.1.224:8081/filebrowser/api/public/dl/0L61fE_a?inline=true"
|
||||
"201819": "http://10.0.1.224:8081/filebrowser/api/public/dl/XJGJ5lG1?inline=true"
|
||||
legacy_ks4_urls:
|
||||
"201516": "http://10.0.1.224:8081/filebrowser/api/public/dl/iaoSkg1v?inline=true"
|
||||
"201617": "http://10.0.1.224:8081/filebrowser/api/public/dl/bqCMUcIH?inline=true"
|
||||
"201718": "http://10.0.1.224:8081/filebrowser/api/public/dl/0L61fE_a?inline=true"
|
||||
"201819": "http://10.0.1.224:8081/filebrowser/api/public/dl/XJGJ5lG1?inline=true"
|
||||
|
||||
- name: tap-uk-ofsted
|
||||
namespace: uk_ofsted
|
||||
|
||||
@@ -40,17 +40,36 @@ def _slug_to_time_period(slug: str) -> str | None:
|
||||
|
||||
|
||||
def get_all_releases(publication_slug: str) -> list[dict]:
|
||||
"""Return all releases for a publication as dicts with 'id' and 'time_period'."""
|
||||
url = f"{CONTENT_API_BASE}/publications/{publication_slug}/releases"
|
||||
"""Return all releases for a publication as dicts with 'id' and 'time_period'.
|
||||
|
||||
The EES content API paginates with a 'paging' envelope when there are many
|
||||
releases. This function follows all pages so no historical release is missed.
|
||||
"""
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
url = f"{CONTENT_API_BASE}/publications/{publication_slug}/releases?page={page}&pageSize=20"
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# API returns either a plain list or a paginated object with a "results" key
|
||||
releases = data if isinstance(data, list) else data.get("results", [])
|
||||
result = []
|
||||
if isinstance(data, list):
|
||||
releases = data
|
||||
total_pages = 1
|
||||
else:
|
||||
releases = data.get("results", [])
|
||||
paging = data.get("paging", {})
|
||||
total_pages = paging.get("totalPages", 1)
|
||||
|
||||
for r in releases:
|
||||
time_period = _slug_to_time_period(r.get("slug", ""))
|
||||
result.append({"id": r["id"], "time_period": time_period})
|
||||
|
||||
if page >= total_pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -663,8 +682,31 @@ class LegacyKS2Stream(Stream):
|
||||
self.logger.warning("Failed to download %s: %s", url, e)
|
||||
continue
|
||||
|
||||
content = resp.content
|
||||
|
||||
# Auto-detect ZIP — the DfE annual archives contain both KS2 and KS4
|
||||
# CSVs in one ZIP. If the download is a ZIP, extract england_ks2final.csv;
|
||||
# otherwise treat the content as a bare CSV (legacy individual-file URLs).
|
||||
csv_bytes = None
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(content))
|
||||
target = next(
|
||||
(n for n in zf.namelist() if "ks2final" in n.lower() and n.endswith(".csv")),
|
||||
None,
|
||||
)
|
||||
if target:
|
||||
with zf.open(target) as f:
|
||||
csv_bytes = f.read()
|
||||
self.logger.info("Extracted %s from ZIP for %s", target, year_code)
|
||||
else:
|
||||
self.logger.warning("No ks2final CSV found in ZIP for %s", year_code)
|
||||
continue
|
||||
except zipfile.BadZipFile:
|
||||
# Not a ZIP — treat as a bare CSV file
|
||||
csv_bytes = content
|
||||
|
||||
df = pd.read_csv(
|
||||
io.BytesIO(resp.content),
|
||||
io.BytesIO(csv_bytes),
|
||||
dtype=str,
|
||||
keep_default_na=False,
|
||||
encoding="latin-1",
|
||||
@@ -693,6 +735,138 @@ class LegacyKS2Stream(Stream):
|
||||
yield record
|
||||
|
||||
|
||||
# ── Legacy KS4 (pre-EES wide format from DfE performance tables) ──────────────
|
||||
# The DfE "Compare School Performance" ZIPs include england_ks4final.csv in a
|
||||
# wide format (one row per school, ~416 columns, uppercase abbreviated names).
|
||||
# EES only hosts 2 years of KS4 data; this stream backfills 2015-16 to 2018-19.
|
||||
# Column mapping: old DfE CSV column → Singer field name (matches stg output).
|
||||
|
||||
_LEGACY_KS4_COLUMN_MAP = {
|
||||
"URN": "urn",
|
||||
"TPUP": "total_pupils",
|
||||
# Attainment 8
|
||||
"ATT8SCR": "attainment_8_score",
|
||||
# Progress 8
|
||||
"P8MEA": "progress_8_score",
|
||||
"P8CILOW": "progress_8_lower_ci",
|
||||
"P8CIUPP": "progress_8_upper_ci",
|
||||
"P8MEAENG": "progress_8_english",
|
||||
"P8MEAMAT": "progress_8_maths",
|
||||
"P8MEAEBAC": "progress_8_ebacc",
|
||||
"P8MEAOPEN": "progress_8_open",
|
||||
# English & Maths pass rates (% suffix stripped at extract time)
|
||||
"PTL2BASICS_95": "english_maths_strong_pass_pct",
|
||||
"PTL2BASICS_94": "english_maths_standard_pass_pct",
|
||||
# EBacc
|
||||
"PTEBACC_E_PTQ_EE": "ebacc_entry_pct",
|
||||
"PTEBACC_95": "ebacc_strong_pass_pct",
|
||||
"PTEBACC_94": "ebacc_standard_pass_pct",
|
||||
# Context
|
||||
"PSENSE4": "sen_ehcp_pct",
|
||||
"PSENAPK4": "sen_support_pct",
|
||||
}
|
||||
|
||||
|
||||
class LegacyKS4Stream(Stream):
|
||||
"""Stream for pre-EES KS4 data from DfE 'Compare School Performance' ZIPs.
|
||||
|
||||
Downloads ZIPs from URLs configured in legacy_ks4_urls (a mapping of
|
||||
6-digit year code → download URL), extracts england_ks4final.csv from each,
|
||||
maps old DfE column names to match stg_ees_ks4 output schema, and emits
|
||||
one record per school per year. The % suffix present on percentage columns
|
||||
(e.g. "39.60%") is stripped here so safe_numeric in dbt can cast cleanly.
|
||||
"""
|
||||
|
||||
name = "legacy_ks4"
|
||||
primary_keys = ["urn", "year"]
|
||||
replication_key = None
|
||||
|
||||
schema = th.PropertiesList(
|
||||
th.Property("urn", th.StringType, required=True),
|
||||
th.Property("year", th.StringType, required=True),
|
||||
th.Property("total_pupils", th.StringType),
|
||||
th.Property("attainment_8_score", th.StringType),
|
||||
th.Property("progress_8_score", th.StringType),
|
||||
th.Property("progress_8_lower_ci", th.StringType),
|
||||
th.Property("progress_8_upper_ci", th.StringType),
|
||||
th.Property("progress_8_english", th.StringType),
|
||||
th.Property("progress_8_maths", th.StringType),
|
||||
th.Property("progress_8_ebacc", th.StringType),
|
||||
th.Property("progress_8_open", th.StringType),
|
||||
th.Property("english_maths_strong_pass_pct", th.StringType),
|
||||
th.Property("english_maths_standard_pass_pct", th.StringType),
|
||||
th.Property("ebacc_entry_pct", th.StringType),
|
||||
th.Property("ebacc_strong_pass_pct", th.StringType),
|
||||
th.Property("ebacc_standard_pass_pct", th.StringType),
|
||||
th.Property("sen_ehcp_pct", th.StringType),
|
||||
th.Property("sen_support_pct", th.StringType),
|
||||
).to_dict()
|
||||
|
||||
def get_records(self, context):
|
||||
import pandas as pd
|
||||
|
||||
url_map = self.config.get("legacy_ks4_urls", {})
|
||||
if not url_map:
|
||||
self.logger.warning("legacy_ks4_urls not configured, skipping legacy KS4")
|
||||
return
|
||||
|
||||
self.logger.info("Loading legacy KS4 for %d year(s)", len(url_map))
|
||||
|
||||
for year_code, url in url_map.items():
|
||||
self.logger.info("Downloading %s for %s", url, year_code)
|
||||
try:
|
||||
resp = requests.get(url, timeout=120)
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
self.logger.warning("Failed to download %s: %s", url, e)
|
||||
continue
|
||||
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(resp.content))
|
||||
except zipfile.BadZipFile as e:
|
||||
self.logger.warning("Not a ZIP for %s: %s", year_code, e)
|
||||
continue
|
||||
|
||||
# Find england_ks4final.csv inside the ZIP
|
||||
target = next(
|
||||
(n for n in zf.namelist() if "ks4final" in n.lower() and n.endswith(".csv")),
|
||||
None,
|
||||
)
|
||||
if not target:
|
||||
self.logger.warning("england_ks4final.csv not found in ZIP for %s", year_code)
|
||||
continue
|
||||
|
||||
with zf.open(target) as f:
|
||||
df = pd.read_csv(
|
||||
f,
|
||||
dtype=str,
|
||||
keep_default_na=False,
|
||||
encoding="latin-1",
|
||||
)
|
||||
|
||||
# Strip BOM from first column name
|
||||
cols = list(df.columns)
|
||||
if cols:
|
||||
cols[0] = cols[0].lstrip("\ufeff").lstrip("")
|
||||
df.columns = cols
|
||||
|
||||
# Filter to school-level rows: URN must be a plain integer
|
||||
if "URN" in df.columns:
|
||||
df = df[df["URN"].str.match(r"^\d+$", na=False)]
|
||||
|
||||
self.logger.info("Emitting %d schools for %s", len(df), year_code)
|
||||
|
||||
for _, row in df.iterrows():
|
||||
record = {"year": year_code}
|
||||
for old_col, new_col in _LEGACY_KS4_COLUMN_MAP.items():
|
||||
val = row.get(old_col, "")
|
||||
# Strip % suffix — legacy DfE CSVs use "39.60%" not "39.60"
|
||||
if isinstance(val, str) and val.endswith("%"):
|
||||
val = val[:-1]
|
||||
record[new_col] = val
|
||||
yield record
|
||||
|
||||
|
||||
class TapUKEES(Tap):
|
||||
"""Singer tap for UK Explore Education Statistics."""
|
||||
|
||||
@@ -711,6 +885,11 @@ class TapUKEES(Tap):
|
||||
th.ObjectType(),
|
||||
description="Mapping of 6-digit year code to download URL for legacy KS2 CSVs (e.g. {\"201819\": \"https://...\"})",
|
||||
),
|
||||
th.Property(
|
||||
"legacy_ks4_urls",
|
||||
th.ObjectType(),
|
||||
description="Mapping of 6-digit year code to download URL for legacy KS4 ZIPs (e.g. {\"201819\": \"https://...\"})",
|
||||
),
|
||||
).to_dict()
|
||||
|
||||
def discover_streams(self):
|
||||
@@ -722,6 +901,7 @@ class TapUKEES(Tap):
|
||||
EESCensusStream(self),
|
||||
EESAdmissionsStream(self),
|
||||
LegacyKS2Stream(self),
|
||||
LegacyKS4Stream(self),
|
||||
EESKs2NationalStream(self),
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
-- Intermediate model: KS4 data chained across academy conversions
|
||||
-- Unions EES (2023/24 onwards) and legacy (2015/16–2018/19) school-level data
|
||||
|
||||
with current_ks4 as (
|
||||
with all_ks4 as (
|
||||
select * from {{ ref('stg_ees_ks4') }}
|
||||
union all
|
||||
select * from {{ ref('stg_legacy_ks4') }}
|
||||
),
|
||||
|
||||
current_ks4 as (
|
||||
select
|
||||
urn as current_urn,
|
||||
urn as source_urn,
|
||||
@@ -11,8 +18,8 @@ with current_ks4 as (
|
||||
english_maths_strong_pass_pct, english_maths_standard_pass_pct,
|
||||
ebacc_entry_pct, ebacc_strong_pass_pct, ebacc_standard_pass_pct, ebacc_avg_score,
|
||||
gcse_grade_91_pct,
|
||||
sen_pct, sen_ehcp_pct, sen_support_pct
|
||||
from {{ ref('stg_ees_ks4') }}
|
||||
sen_pct, sen_support_pct, sen_ehcp_pct
|
||||
from all_ks4
|
||||
),
|
||||
|
||||
predecessor_ks4 as (
|
||||
@@ -27,12 +34,12 @@ predecessor_ks4 as (
|
||||
ks4.english_maths_strong_pass_pct, ks4.english_maths_standard_pass_pct,
|
||||
ks4.ebacc_entry_pct, ks4.ebacc_strong_pass_pct, ks4.ebacc_standard_pass_pct, ks4.ebacc_avg_score,
|
||||
ks4.gcse_grade_91_pct,
|
||||
ks4.sen_pct, ks4.sen_ehcp_pct, ks4.sen_support_pct
|
||||
from {{ ref('stg_ees_ks4') }} ks4
|
||||
ks4.sen_pct, ks4.sen_support_pct, ks4.sen_ehcp_pct
|
||||
from all_ks4 ks4
|
||||
inner join {{ ref('int_school_lineage') }} lin
|
||||
on ks4.urn = lin.predecessor_urn
|
||||
where not exists (
|
||||
select 1 from {{ ref('stg_ees_ks4') }} curr
|
||||
select 1 from all_ks4 curr
|
||||
where curr.urn = lin.current_urn
|
||||
and curr.year = ks4.year
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ with schools as (
|
||||
|
||||
{% set ofsted_relation = adapter.get_relation(
|
||||
database=target.database,
|
||||
schema=target.schema,
|
||||
schema='intermediate',
|
||||
identifier='int_ofsted_latest'
|
||||
) %}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ select
|
||||
urn,
|
||||
year,
|
||||
school_phase,
|
||||
published_admission_number,
|
||||
places_offered,
|
||||
total_applications,
|
||||
first_preference_applications,
|
||||
first_preference_offers,
|
||||
|
||||
@@ -39,6 +39,9 @@ sources:
|
||||
- name: ees_ks4_info
|
||||
description: KS4 school information (wide format — context/demographics per school)
|
||||
|
||||
- name: legacy_ks4
|
||||
description: Pre-EES KS4 school-level data (2015/16–2018/19) from DfE Compare School Performance ZIPs
|
||||
|
||||
- name: ees_census
|
||||
description: School census pupil characteristics
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ renamed as (
|
||||
entry_year,
|
||||
|
||||
-- Places and offers
|
||||
{{ safe_numeric('total_number_places_offered') }}::integer as published_admission_number,
|
||||
-- places_offered: number of places the school actually offered in this
|
||||
-- year's admissions round. NOT the Published Admission Number (PAN),
|
||||
-- which is the school's published capacity — EES does not expose PAN
|
||||
-- at school level, so we use the count of offers as the best proxy.
|
||||
{{ safe_numeric('total_number_places_offered') }}::integer as places_offered,
|
||||
{{ safe_numeric('number_preferred_offers') }}::integer as total_offers,
|
||||
{{ safe_numeric('number_1st_preference_offers') }}::integer as first_preference_offers,
|
||||
{{ safe_numeric('number_2nd_preference_offers') }}::integer as second_preference_offers,
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
-- Staging model: KS4 attainment data from EES
|
||||
-- KS4 performance data is long-format with breakdown dimensions (breakdown_topic,
|
||||
-- breakdown, sex). Unlike KS2 which has a subject dimension, KS4 metrics are
|
||||
-- already in separate columns — we just filter to the 'All pupils' breakdown.
|
||||
-- already in separate columns — we just filter to the all-pupils total row.
|
||||
-- EES uses 'z' (not applicable) and 'c' (confidential) as suppression codes —
|
||||
-- safe_numeric handles both by treating any non-numeric string as NULL.
|
||||
-- NOTE: older EES releases (pre-2023/24) use breakdown_topic = 'All pupils';
|
||||
-- the 2023/24 release switched to breakdown_topic = 'Total'. Both are included.
|
||||
|
||||
with performance as (
|
||||
select * from {{ source('raw', 'ees_ks4_performance') }}
|
||||
@@ -46,7 +48,7 @@ all_pupils as (
|
||||
{{ safe_numeric('gcse_91_percent') }} as gcse_grade_91_pct
|
||||
|
||||
from performance
|
||||
where breakdown_topic = 'Total'
|
||||
where breakdown_topic in ('Total', 'All pupils')
|
||||
and breakdown = 'Total'
|
||||
and sex = 'Total'
|
||||
),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
-- Staging model: Legacy KS4 data from pre-EES DfE performance tables
|
||||
-- Covers 2015/16 – 2018/19; EES provides 2023/24 onwards.
|
||||
-- The tap already maps old column names and strips % suffixes;
|
||||
-- this model just applies safe_numeric casts and adds NULL placeholders
|
||||
-- for columns not available in the legacy format.
|
||||
|
||||
select
|
||||
cast(trim(urn) as integer) as urn,
|
||||
cast(trim(year) as integer) as year,
|
||||
|
||||
{{ safe_numeric('total_pupils') }}::integer as total_pupils,
|
||||
{{ safe_numeric('total_pupils') }}::integer as eligible_pupils,
|
||||
null::numeric as prior_attainment_avg,
|
||||
|
||||
-- Attainment 8
|
||||
{{ safe_numeric('attainment_8_score') }} as attainment_8_score,
|
||||
|
||||
-- Progress 8
|
||||
{{ safe_numeric('progress_8_score') }} as progress_8_score,
|
||||
{{ safe_numeric('progress_8_lower_ci') }} as progress_8_lower_ci,
|
||||
{{ safe_numeric('progress_8_upper_ci') }} as progress_8_upper_ci,
|
||||
{{ safe_numeric('progress_8_english') }} as progress_8_english,
|
||||
{{ safe_numeric('progress_8_maths') }} as progress_8_maths,
|
||||
{{ safe_numeric('progress_8_ebacc') }} as progress_8_ebacc,
|
||||
{{ safe_numeric('progress_8_open') }} as progress_8_open,
|
||||
|
||||
-- English & Maths pass rates
|
||||
{{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
|
||||
{{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
|
||||
|
||||
-- EBacc
|
||||
{{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
|
||||
{{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
|
||||
{{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
|
||||
null::numeric as ebacc_avg_score,
|
||||
|
||||
-- GCSE grade 9-1 (not published in legacy format)
|
||||
null::numeric as gcse_grade_91_pct,
|
||||
|
||||
-- SEN
|
||||
null::numeric as sen_pct,
|
||||
{{ safe_numeric('sen_support_pct') }} as sen_support_pct,
|
||||
{{ safe_numeric('sen_ehcp_pct') }} as sen_ehcp_pct
|
||||
|
||||
from {{ source('raw', 'legacy_ks4') }}
|
||||
where urn is not null
|
||||
and urn ~ '^[0-9]+$'
|
||||
Reference in New Issue
Block a user