Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbb74d9b60 | ||
|
|
9545aec7f4 | ||
|
|
3365ebcb3a | ||
|
|
6d79bd3331 | ||
|
|
24e114dee7 | ||
|
|
6c5db0c266 | ||
|
|
6f749ed21f | ||
|
|
d423826840 | ||
|
|
d3c63ccc6d | ||
|
|
b93eb3a691 | ||
|
|
6b871ce1e9 | ||
|
|
c981d89137 | ||
|
|
de5e790112 | ||
|
|
42138fc402 | ||
|
|
c5af476213 | ||
|
|
de853b90b3 | ||
|
|
759d9f5cea | ||
|
|
555d3f0a7d | ||
|
|
ecc847091c | ||
|
|
b187a478c9 | ||
|
|
c0547c45e5 | ||
|
|
4a3928df9f | ||
|
|
07c97a46c5 | ||
|
|
bb81337aba | ||
|
|
b34511e459 | ||
|
|
f928a15c1e | ||
|
|
1fc1e07d21 | ||
|
|
2208ad93c1 | ||
|
|
24f3cb4c65 | ||
|
|
3816b92d06 | ||
|
|
51ce2d6373 | ||
|
|
5ddc7314fd | ||
|
|
8ad2070e76 | ||
|
|
3aad5101a8 | ||
|
|
c47fe38971 | ||
|
|
8f211577c8 | ||
|
|
69f2201244 | ||
|
|
e6048c9ca6 | ||
|
|
7650b16f62 | ||
|
|
9abd020967 | ||
|
|
228eb214f5 | ||
|
|
ea5249a2ea | ||
|
|
ffe7e04951 | ||
|
|
c9a1892bfb | ||
|
|
50b599a09b | ||
|
|
94151c58ea | ||
|
|
8bf6145a73 | ||
|
|
a72323874f | ||
|
|
5a71f54d94 | ||
|
|
88c653215d | ||
|
|
5156a85bd1 | ||
|
|
fa1abff642 | ||
|
|
38acc76555 | ||
|
|
c7e0c2eac7 | ||
|
|
59ac9c10b9 | ||
|
|
eddf74745f | ||
|
|
d5f3fdf0f9 | ||
|
|
3015c37bac | ||
|
|
153b26a32f | ||
|
|
e373241d51 | ||
|
|
4043270a77 | ||
|
|
e65688d600 | ||
|
|
7f4dfa2748 | ||
|
|
bdaa05cd54 | ||
|
|
043506cb6b | ||
|
|
0466986195 | ||
|
|
3f0e05cc99 | ||
|
|
f1819e9c4d | ||
|
|
97ac5c9cef | ||
|
|
dc22fd2853 | ||
|
|
6b117dd26c | ||
|
|
9ee4d45a94 | ||
|
|
b11ee3c8de | ||
|
|
eecb84fb35 | ||
|
|
39e5c11274 | ||
|
|
accc562b90 | ||
|
|
f31f6f8404 | ||
|
|
8d50afef1e | ||
|
|
45a3e7fb8f | ||
|
|
91b49f1a59 | ||
|
|
2433101fa0 | ||
|
|
f43a8ecb69 | ||
|
|
8c3a5cc4e9 | ||
|
|
8ab0ac0a04 | ||
|
|
c298a29b61 | ||
|
|
e098ad4bd1 | ||
|
|
5f961bf7f4 | ||
|
|
803e68970c | ||
|
|
ec2d12478e | ||
|
|
4c229aec6e | ||
|
|
752eb07310 | ||
|
|
a7f6ff4035 | ||
|
|
2f786787b9 | ||
|
|
5044c24895 | ||
|
|
d74cc95034 | ||
|
|
7c43a1baaf | ||
|
|
bcf3498086 | ||
|
|
7199c3a90b | ||
|
|
97dd69a512 | ||
|
|
d1c92ea7d5 |
No files matched your search
@@ -5,3 +5,9 @@ __pycache__/
|
||||
pipeline/transform/target/
|
||||
pipeline/transform/logs/
|
||||
pipeline/transform/.user.yml
|
||||
|
||||
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
||||
.playwright-mcp/
|
||||
|
||||
# Playwright run artefacts written when the suite is run from the repo root
|
||||
test-results/
|
||||
+301
-50
@@ -7,6 +7,7 @@ Uses real data from UK Government Compare School Performance downloads.
|
||||
import hashlib
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -34,6 +35,7 @@ from .data_loader import (
|
||||
search_schools_typesense,
|
||||
)
|
||||
from .data_loader import get_data_info as get_db_info
|
||||
from .places import build_place_registry
|
||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||
from .utils import clean_for_json, convert_to_native
|
||||
|
||||
@@ -48,11 +50,20 @@ PHASE_GROUPS: dict[str, set[str]] = {
|
||||
"all-through": {"all-through"},
|
||||
}
|
||||
|
||||
BASE_URL = "https://schoolcompare.co.uk"
|
||||
# Must match SITE_URL in nextjs-app/lib/site.ts. The apex 301s to www, and a
|
||||
# sitemap <loc> that redirects wastes a crawl on every URL it lists.
|
||||
BASE_URL = "https://www.schoolcompare.co.uk"
|
||||
MAX_SLUG_LENGTH = 60
|
||||
|
||||
# In-memory sitemap cache
|
||||
_sitemap_xml: str | None = None
|
||||
# In-memory sitemap cache: name -> XML. Populated on startup and by the admin
|
||||
# regenerate endpoint after a pipeline run.
|
||||
_sitemaps: dict[str, str] | None = None
|
||||
|
||||
# Built from the same DataFrame the sitemap uses, so places and sitemap can
|
||||
# never describe different corpora. Reset by the same admin endpoint.
|
||||
_place_registry: dict | None = None
|
||||
|
||||
VALID_PLACE_KINDS = ("town", "locality", "authority", "outcode")
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
@@ -70,43 +81,206 @@ def _school_url(urn: int, school_name: str) -> str:
|
||||
return f"/school/{urn}-{slug}"
|
||||
|
||||
|
||||
def build_sitemap() -> str:
|
||||
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
|
||||
# Routes worth submitting that are not a school page. /admissions was missing
|
||||
# from the sitemap entirely despite being a static, indexable guide.
|
||||
STATIC_SITEMAP_PATHS = ("/", "/rankings", "/compare", "/admissions")
|
||||
|
||||
|
||||
# A page has something a search result could state if any of these is present
|
||||
# in any year. Shared by _has_publishable_data and the per-school check in
|
||||
# _school_sitemap_rows so the two can never drift.
|
||||
_PUBLISHABLE_FIELDS = ("rwm_expected_pct", "attainment_8_score", "ofsted_grade")
|
||||
|
||||
|
||||
def _has_publishable_data(row) -> bool:
|
||||
"""True when a school page has something a search result could state.
|
||||
|
||||
A school with no results in any year and no Ofsted grade renders an empty
|
||||
page. Submitting it spends crawl budget and drags the corpus-wide quality
|
||||
signal down, so it stays out of the sitemap. The page itself still resolves
|
||||
for anyone who has the URL.
|
||||
"""
|
||||
for field in _PUBLISHABLE_FIELDS:
|
||||
value = row.get(field)
|
||||
if value is not None and not pd.isna(value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _url_element(loc: str, lastmod: str | None = None) -> str:
|
||||
"""One <url> entry. No priority or changefreq — Google ignores both."""
|
||||
body = f"<loc>{loc}</loc>"
|
||||
if lastmod:
|
||||
body += f"<lastmod>{lastmod}</lastmod>"
|
||||
return f" <url>{body}</url>"
|
||||
|
||||
|
||||
def _school_sitemap_rows(df) -> list[str]:
|
||||
"""A <url> element per school that has something to show.
|
||||
|
||||
lastmod comes from the school's Ofsted date where there is one and is
|
||||
omitted otherwise. An always-now lastmod is a claim Google learns to
|
||||
distrust; an absent one honestly means "unknown".
|
||||
"""
|
||||
if df.empty or "urn" not in df.columns or "school_name" not in df.columns:
|
||||
return []
|
||||
|
||||
rows: list[str] = []
|
||||
seen: set[int] = set()
|
||||
|
||||
# Publishable is a property of the SCHOOL, not of its latest row.
|
||||
#
|
||||
# The first cut tested the latest year's row alone, which quietly dropped
|
||||
# every school that has results in its history but a null row for the most
|
||||
# recent year — a school that stopped reporting, or whose figures were
|
||||
# suppressed for small-cohort disclosure. The Mallard Academy (150367) is
|
||||
# the case that caught it: real KS2 results for 2015-16 through 2018-19,
|
||||
# then null rows for 2022-23 onward. Its page shows all four years; the
|
||||
# sitemap omitted it. Roughly 220 schools were affected.
|
||||
publishable_cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
|
||||
publishable: set[int] = (
|
||||
set(df.loc[df[publishable_cols].notna().any(axis=1), "urn"].astype(int))
|
||||
if publishable_cols else set()
|
||||
)
|
||||
|
||||
# Latest row per URN first, so a school's most recent Ofsted date wins.
|
||||
ordered = df.sort_values("year", ascending=False) if "year" in df.columns else df
|
||||
|
||||
for _, row in ordered.iterrows():
|
||||
urn = int(row["urn"])
|
||||
if urn in seen:
|
||||
continue
|
||||
seen.add(urn)
|
||||
if urn not in publishable:
|
||||
continue
|
||||
|
||||
lastmod = None
|
||||
ofsted_date = row.get("ofsted_date")
|
||||
if ofsted_date is not None and not pd.isna(ofsted_date):
|
||||
lastmod = pd.Timestamp(ofsted_date).date().isoformat()
|
||||
|
||||
rows.append(_url_element(
|
||||
BASE_URL + _school_url(urn, str(row["school_name"])), lastmod))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# Sitemaps cap at 50,000 URLs per file. 10,000 keeps a child small enough to
|
||||
# scan by eye in Search Console, which is the point of splitting at all:
|
||||
# coverage is reported per submitted sitemap, so one file per page family is
|
||||
# what makes an indexation problem attributable to a family.
|
||||
SITEMAP_CHUNK_SIZE = 10_000
|
||||
|
||||
# Children are served under /sitemaps/ because Next.js only treats a whole
|
||||
# bracketed path segment as dynamic — a route folder named "sitemap-[...parts]"
|
||||
# is read as a literal static segment and never matches.
|
||||
SITEMAP_CHILD_PREFIX = "/sitemaps"
|
||||
|
||||
|
||||
def get_place_registry() -> dict:
|
||||
"""The place registry, built once and cached for the process."""
|
||||
global _place_registry
|
||||
if _place_registry is None:
|
||||
_place_registry = build_place_registry(load_school_data())
|
||||
return _place_registry
|
||||
|
||||
|
||||
def _urlset(rows: list[str]) -> str:
|
||||
return "\n".join([
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
*rows,
|
||||
"</urlset>",
|
||||
])
|
||||
|
||||
|
||||
def _place_url(place) -> str:
|
||||
"""The canonical path for a place. Two namespaces, per the spec.
|
||||
|
||||
Towns and localities share /schools/[place]; authorities take their own
|
||||
prefix because 67 town names collide with an authority name and neither
|
||||
set contains the other.
|
||||
"""
|
||||
if place.kind == "authority":
|
||||
return f"/schools/authority/{place.slug}"
|
||||
if place.kind == "outcode":
|
||||
return f"/schools/near/{place.slug}"
|
||||
return f"/schools/{place.slug}"
|
||||
|
||||
|
||||
def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]:
|
||||
"""A <url> per place, plus a phase variant wherever that phase clears the
|
||||
threshold on its own.
|
||||
|
||||
Phase is part of the query — "primary schools in beccles" — so each
|
||||
variant is its own indexable page. Submitting only the bare place URL left
|
||||
~950 of them reachable by nothing: absent from every sitemap, and not
|
||||
linked from the place page either.
|
||||
"""
|
||||
rows: list[str] = []
|
||||
for p in sorted(get_place_registry().values(), key=lambda p: (p.kind, p.slug)):
|
||||
if p.kind not in kinds:
|
||||
continue
|
||||
rows.append(_url_element(BASE_URL + _place_url(p)))
|
||||
# Outcodes carry no phase variants: nobody searches "primary schools
|
||||
# in SW11", so the routes do not exist to submit.
|
||||
if p.kind == "outcode":
|
||||
continue
|
||||
for phase in ("primary", "secondary"):
|
||||
if p.publishes_phase(phase):
|
||||
rows.append(_url_element(f"{BASE_URL}{_place_url(p)}/{phase}"))
|
||||
return rows
|
||||
|
||||
|
||||
def build_sitemaps() -> dict[str, str]:
|
||||
"""Build the sitemap index and every child, keyed by name."""
|
||||
df = load_school_data()
|
||||
|
||||
static_urls = [
|
||||
(BASE_URL + "/", "daily", "1.0"),
|
||||
(BASE_URL + "/rankings", "weekly", "0.8"),
|
||||
(BASE_URL + "/compare", "weekly", "0.8"),
|
||||
children: dict[str, str] = {
|
||||
"static.xml": _urlset(
|
||||
[_url_element(BASE_URL + path) for path in STATIC_SITEMAP_PATHS]),
|
||||
}
|
||||
|
||||
school_rows = _school_sitemap_rows(df)
|
||||
# Always emit at least one school child, so the index shape is stable even
|
||||
# on an empty database.
|
||||
chunks = [school_rows[i:i + SITEMAP_CHUNK_SIZE]
|
||||
for i in range(0, len(school_rows), SITEMAP_CHUNK_SIZE)] or [[]]
|
||||
for n, chunk in enumerate(chunks, start=1):
|
||||
children[f"schools-{n}.xml"] = _urlset(chunk)
|
||||
|
||||
# Separate children per family: Search Console reports coverage per
|
||||
# submitted sitemap, which is how the location layer's indexation is
|
||||
# measured apart from the school pages'.
|
||||
for label, kinds in (("places", ("town", "locality", "authority")),
|
||||
("outcodes", ("outcode",))):
|
||||
rows = _place_sitemap_rows(kinds)
|
||||
chunks = [rows[i:i + SITEMAP_CHUNK_SIZE]
|
||||
for i in range(0, len(rows), SITEMAP_CHUNK_SIZE)] or [[]]
|
||||
for n, chunk in enumerate(chunks, start=1):
|
||||
children[f"{label}-{n}.xml"] = _urlset(chunk)
|
||||
|
||||
# On a sitemap index, lastmod means "when this sitemap file last changed",
|
||||
# so generation time is the correct value here — unlike on a <url>, where
|
||||
# it would be a claim about content we cannot support.
|
||||
generated = datetime.now(timezone.utc).date().isoformat()
|
||||
index_rows = [
|
||||
f" <sitemap><loc>{BASE_URL}{SITEMAP_CHILD_PREFIX}/{name}</loc>"
|
||||
f"<lastmod>{generated}</lastmod></sitemap>"
|
||||
for name in children
|
||||
]
|
||||
index = "\n".join([
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
*index_rows,
|
||||
"</sitemapindex>",
|
||||
])
|
||||
return {**children, "sitemap.xml": index}
|
||||
|
||||
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
||||
|
||||
for url, freq, priority in static_urls:
|
||||
lines.append(
|
||||
f" <url><loc>{url}</loc>"
|
||||
f"<changefreq>{freq}</changefreq>"
|
||||
f"<priority>{priority}</priority></url>"
|
||||
)
|
||||
|
||||
if not df.empty and "urn" in df.columns and "school_name" in df.columns:
|
||||
seen = set()
|
||||
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
|
||||
urn = int(row["urn"])
|
||||
name = str(row["school_name"])
|
||||
if urn in seen:
|
||||
continue
|
||||
seen.add(urn)
|
||||
path = _school_url(urn, name)
|
||||
lines.append(
|
||||
f" <url><loc>{BASE_URL}{path}</loc>"
|
||||
f"<changefreq>monthly</changefreq>"
|
||||
f"<priority>0.6</priority></url>"
|
||||
)
|
||||
|
||||
lines.append("</urlset>")
|
||||
return "\n".join(lines)
|
||||
def build_sitemap() -> str:
|
||||
"""The sitemap index. Kept for `lifespan` and the admin endpoint."""
|
||||
return build_sitemaps()["sitemap.xml"]
|
||||
|
||||
|
||||
def clean_filter_values(series: pd.Series) -> list[str]:
|
||||
@@ -284,7 +458,7 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan - startup and shutdown events."""
|
||||
global _sitemap_xml
|
||||
global _sitemaps
|
||||
print("Loading school data from marts...")
|
||||
df = load_school_data()
|
||||
if df.empty:
|
||||
@@ -294,9 +468,9 @@ async def lifespan(app: FastAPI):
|
||||
# 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>")
|
||||
print(f"Sitemap built: {n} URLs.")
|
||||
_sitemaps = build_sitemaps()
|
||||
n = sum(x.count("<url>") for x in _sitemaps.values())
|
||||
print(f"Sitemaps built: {len(_sitemaps)} files, {n} URLs.")
|
||||
except Exception as e:
|
||||
print(f"Warning: sitemap build failed on startup: {e}")
|
||||
|
||||
@@ -632,6 +806,7 @@ async def get_school_details(request: Request, urn: int):
|
||||
"census": supplementary.get("census"),
|
||||
"admissions": supplementary.get("admissions"),
|
||||
"admissions_history": supplementary.get("admissions_history") or [],
|
||||
"admission_distance": supplementary.get("admission_distance"),
|
||||
"sen_detail": supplementary.get("sen_detail"),
|
||||
"phonics": supplementary.get("phonics"),
|
||||
"deprivation": supplementary.get("deprivation"),
|
||||
@@ -1006,6 +1181,66 @@ async def get_rankings(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/places")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def list_places(request: Request):
|
||||
"""Every published place. The sitemap and the link modules read this."""
|
||||
registry = get_place_registry()
|
||||
return {"places": [
|
||||
{"kind": p.kind, "slug": p.slug, "name": p.name, "count": len(p.urns)}
|
||||
for p in sorted(registry.values(), key=lambda p: (p.kind, p.slug))
|
||||
]}
|
||||
|
||||
|
||||
@app.get("/api/places/{kind}/{slug}")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_place(request: Request, kind: str, slug: str,
|
||||
phase: Optional[str] = None):
|
||||
"""One place: its schools ranked, and its local averages."""
|
||||
if kind not in VALID_PLACE_KINDS:
|
||||
raise HTTPException(status_code=404, detail="No such place")
|
||||
|
||||
place = get_place_registry().get(f"{kind}:{slug}")
|
||||
if place is None:
|
||||
raise HTTPException(status_code=404, detail="No such place")
|
||||
|
||||
df = load_latest_school_data()
|
||||
rows = df[df["urn"].isin(place.urns)]
|
||||
|
||||
if phase:
|
||||
wanted = PHASE_GROUPS.get(phase.lower())
|
||||
if wanted and "phase" in rows.columns:
|
||||
rows = rows[rows["phase"].fillna("").str.lower().isin(wanted)]
|
||||
|
||||
# The metric the page ranks on, which is also the one it averages.
|
||||
metric = "attainment_8_score" if phase == "secondary" else "rwm_expected_pct"
|
||||
if metric in rows.columns:
|
||||
rows = rows.sort_values(metric, ascending=False, na_position="last")
|
||||
|
||||
averages = {
|
||||
m: (None if m not in rows.columns or rows[m].dropna().empty
|
||||
else float(rows[m].dropna().mean()))
|
||||
for m in ("rwm_expected_pct", "attainment_8_score")
|
||||
}
|
||||
|
||||
cols = [c for c in SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
|
||||
"rwm_expected_pct", "attainment_8_score",
|
||||
"total_pupils"]
|
||||
if c in rows.columns]
|
||||
|
||||
return {
|
||||
"place": {"kind": place.kind, "slug": place.slug, "name": place.name,
|
||||
"count": len(place.urns),
|
||||
"parent_authority": place.parent_authority,
|
||||
# Only phases that clear the threshold, so the page links
|
||||
# variants that exist rather than 404s.
|
||||
"phases": [ph for ph in ("primary", "secondary")
|
||||
if place.publishes_phase(ph)]},
|
||||
"schools": clean_for_json(rows[cols]),
|
||||
"averages": averages,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/data-info")
|
||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||
async def get_data_info(request: Request):
|
||||
@@ -1086,16 +1321,28 @@ async def robots_txt():
|
||||
return FileResponse(settings.frontend_dir / "robots.txt", media_type="text/plain")
|
||||
|
||||
|
||||
@app.get("/sitemap.xml")
|
||||
async def sitemap_xml():
|
||||
"""Serve sitemap.xml for search engine indexing."""
|
||||
global _sitemap_xml
|
||||
if _sitemap_xml is None:
|
||||
def _serve_sitemap(name: str) -> Response:
|
||||
global _sitemaps
|
||||
if _sitemaps is None:
|
||||
try:
|
||||
_sitemap_xml = build_sitemap()
|
||||
_sitemaps = build_sitemaps()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
|
||||
return Response(content=_sitemap_xml, media_type="application/xml")
|
||||
if name not in _sitemaps:
|
||||
raise HTTPException(status_code=404, detail="No such sitemap")
|
||||
return Response(content=_sitemaps[name], media_type="application/xml")
|
||||
|
||||
|
||||
@app.get("/sitemap.xml")
|
||||
async def sitemap_xml():
|
||||
"""Serve the sitemap index."""
|
||||
return _serve_sitemap("sitemap.xml")
|
||||
|
||||
|
||||
@app.get("/sitemaps/{name}")
|
||||
async def sitemap_child(name: str):
|
||||
"""Serve a child sitemap (static.xml, or schools-N.xml)."""
|
||||
return _serve_sitemap(name)
|
||||
|
||||
|
||||
@app.post("/api/admin/regenerate-sitemap")
|
||||
@@ -1105,10 +1352,14 @@ async def regenerate_sitemap(
|
||||
_: bool = Depends(verify_admin_api_key),
|
||||
):
|
||||
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
|
||||
global _sitemap_xml
|
||||
_sitemap_xml = build_sitemap()
|
||||
n = _sitemap_xml.count("<url>")
|
||||
return {"status": "ok", "urls": n}
|
||||
global _sitemaps, _place_registry
|
||||
# Places and sitemap are rebuilt together — they read the same marts, and
|
||||
# letting them drift apart would submit URLs for places that no longer
|
||||
# exist.
|
||||
_place_registry = None
|
||||
_sitemaps = build_sitemaps()
|
||||
n = sum(x.count("<url>") for x in _sitemaps.values())
|
||||
return {"status": "ok", "urls": n, "sitemaps": len(_sitemaps)}
|
||||
|
||||
|
||||
# Mount static files directly (must be after all routes to avoid catching API calls)
|
||||
|
||||
+39
-1
@@ -18,7 +18,7 @@ from .config import settings
|
||||
from .database import SessionLocal, engine
|
||||
from .models import (
|
||||
DimSchool, DimLocation, KS2Performance,
|
||||
FactOfstedInspection, FactAdmissions,
|
||||
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
|
||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||
)
|
||||
from .ofsted_codes import ofsted_page_url, report_card_labels
|
||||
@@ -723,6 +723,17 @@ def _admissions_row_dict(a) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _admission_distance_dict(d) -> dict:
|
||||
"""Serialize one fact_admission_distance row for API responses."""
|
||||
return {
|
||||
"year": d.year,
|
||||
"distance_m": d.distance_m,
|
||||
"route_count": d.route_count,
|
||||
"la_name": d.la_name,
|
||||
"distance_unit_raw": d.distance_unit_raw,
|
||||
}
|
||||
|
||||
|
||||
def _census_dict(pc) -> dict:
|
||||
return {
|
||||
"year": pc.year,
|
||||
@@ -759,6 +770,7 @@ def _empty_supplementary() -> dict:
|
||||
"census": None,
|
||||
"admissions": None,
|
||||
"admissions_history": [],
|
||||
"admission_distance": None,
|
||||
"sen_detail": None,
|
||||
"phonics": None,
|
||||
"deprivation": None,
|
||||
@@ -837,6 +849,32 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
|
||||
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
||||
_safe(_admissions)
|
||||
|
||||
# Last distance offered — the latest year per URN, and only that.
|
||||
#
|
||||
# The mart holds every published year and the DAG keeps loading them; what
|
||||
# changed is what leaves this process. Earlier years are being held back as
|
||||
# a paid feature, and this API is public and unauthenticated — serving the
|
||||
# history here would hand it to anyone who opened the network tab, whatever
|
||||
# the page chose to render. Withholding it in the client would have been
|
||||
# decoration, not a decision.
|
||||
#
|
||||
# Restoring it for entitled callers is a change to this function, not to
|
||||
# the pipeline: fact_admission_distance is untouched and complete.
|
||||
def _admission_distance():
|
||||
rows = (
|
||||
db.query(FactAdmissionDistance)
|
||||
.filter(FactAdmissionDistance.urn.in_(urns))
|
||||
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc())
|
||||
.all()
|
||||
)
|
||||
seen = set()
|
||||
for d in rows:
|
||||
if d.urn in seen:
|
||||
continue
|
||||
seen.add(d.urn)
|
||||
result[d.urn]["admission_distance"] = _admission_distance_dict(d)
|
||||
_safe(_admission_distance)
|
||||
|
||||
# Deprivation — one row per URN.
|
||||
def _deprivation():
|
||||
rows = (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Curated London localities, defined by the postcode districts they cover.
|
||||
|
||||
The GIAS `town` field puts 1,819 London schools under the single value
|
||||
"London", so it cannot answer "schools in Battersea" — a query that appears in
|
||||
the Search Console baseline. No single field can: parliamentary constituency
|
||||
gives Battersea but not Canary Wharf; postcodes.io's admin_ward gives Canary
|
||||
Wharf but not Battersea; neither gives Clapham or Shoreditch, which are postal
|
||||
and colloquial rather than administrative.
|
||||
|
||||
So this is curated. Where a locality ends is a judgement, not a fact, and a
|
||||
reviewable file is the honest place for a judgement. No new ingestion is
|
||||
needed — the corpus already carries postcodes.
|
||||
|
||||
This is the canonical copy. `pipeline/transform/seeds/locality_outcodes.csv`
|
||||
mirrors it for anyone querying the warehouse directly; the backend image does
|
||||
not contain `pipeline/`, which is why the module rather than the seed is
|
||||
canonical. Same arrangement as `backend/gias_codes.py`.
|
||||
|
||||
A locality whose outcodes hold fewer than MIN_SCHOOLS schools is not
|
||||
published, so a typo produces no page rather than an empty one. Places that
|
||||
fail that check are logged at startup, because a locality you meant to publish
|
||||
quietly not appearing is the failure worth hearing about.
|
||||
|
||||
Two rules for anything added here.
|
||||
|
||||
**Sub-borough districts only.** A London borough is a local authority and
|
||||
already has a page at /schools/authority/[la] covering all of its schools; a
|
||||
locality defined by two or three outcodes would be a partial, near-duplicate
|
||||
subset of it. Hackney, Islington, Greenwich and Ealing were all in the first
|
||||
draft for that reason and have been removed.
|
||||
|
||||
**The slug must not match a GIAS town.** "Richmond" did — GIAS has a Richmond
|
||||
in North Yorkshire with 37 schools — so the London one could never publish.
|
||||
The registry skips any locality that collides and logs it.
|
||||
"""
|
||||
|
||||
# slug -> (display name, outcodes)
|
||||
LOCALITY_OUTCODES: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
"battersea": ("Battersea", ("SW11",)),
|
||||
"canary-wharf": ("Canary Wharf", ("E14",)),
|
||||
"clapham": ("Clapham", ("SW4",)),
|
||||
"shoreditch": ("Shoreditch", ("EC2A", "E1")),
|
||||
"peckham": ("Peckham", ("SE15",)),
|
||||
"brixton": ("Brixton", ("SW2", "SW9")),
|
||||
"camden-town": ("Camden Town", ("NW1",)),
|
||||
"wimbledon": ("Wimbledon", ("SW19",)),
|
||||
"putney": ("Putney", ("SW15",)),
|
||||
"fulham": ("Fulham", ("SW6",)),
|
||||
"chiswick": ("Chiswick", ("W4",)),
|
||||
"stratford": ("Stratford", ("E15",)),
|
||||
"walthamstow": ("Walthamstow", ("E17",)),
|
||||
"tooting": ("Tooting", ("SW17",)),
|
||||
"dulwich": ("Dulwich", ("SE21", "SE22")),
|
||||
}
|
||||
@@ -188,6 +188,37 @@ class FactAdmissions(Base):
|
||||
admissions_policy = Column(String(100))
|
||||
|
||||
|
||||
class FactAdmissionDistance(Base):
|
||||
"""Last distance offered — one row per URN per year.
|
||||
|
||||
Separate from FactAdmissions because the source is separate: EES publishes
|
||||
admissions for the whole country, whereas cut-off distances exist only for
|
||||
the local authorities that choose to publish them (57 at the time of
|
||||
writing), and the two refresh on unrelated timetables.
|
||||
"""
|
||||
__tablename__ = "fact_admission_distance"
|
||||
__table_args__ = (
|
||||
Index("ix_admission_distance_urn_year", "urn", "year"),
|
||||
MARTS,
|
||||
)
|
||||
|
||||
urn = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, primary_key=True)
|
||||
# Straight-line distance in metres from the school to the last home offered
|
||||
# a place that year.
|
||||
distance_m = Column(Float)
|
||||
# How many admission routes (ability bands, separate reception/junior
|
||||
# intakes) were collapsed into distance_m. >1 means the figure is the
|
||||
# furthest of several and the page must say so.
|
||||
route_count = Column(Integer)
|
||||
la_code = Column(Integer)
|
||||
la_name = Column(String(100))
|
||||
# The unit the council published in, so the page can lead with the unit a
|
||||
# parent was given rather than always converting.
|
||||
distance_unit_raw = Column(String(20))
|
||||
source_file = Column(Text)
|
||||
|
||||
|
||||
class FactPupilCharacteristics(Base):
|
||||
"""School pupil composition from EES census — one row per URN per year."""
|
||||
__tablename__ = "fact_pupil_characteristics"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""The place registry: what places the site publishes, and what is in each.
|
||||
|
||||
One module owns this question. The pages, the sitemap and the internal-link
|
||||
modules all read from here, so the threshold and the collision rules exist in
|
||||
exactly one place and are testable without a browser or a database.
|
||||
|
||||
Two namespaces, never one. 67 viable town names collide with a local
|
||||
authority name, and the authority is the larger set in only 43 of them —
|
||||
postal towns cross authority boundaries, so neither can absorb the other.
|
||||
Keys are "<kind>:<slug>" so the collision cannot reappear in the dict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Five schools with publishable data. Below this a place has nothing to say
|
||||
# that a list of schools does not, and publishing it is index bloat.
|
||||
MIN_SCHOOLS = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Place:
|
||||
kind: str # "town" | "locality" | "authority" | "outcode"
|
||||
slug: str
|
||||
name: str
|
||||
urns: tuple[int, ...]
|
||||
parent_authority: str | None # authority NAME, for the 301 target
|
||||
# URNs per phase, so the per-phase threshold can be applied without
|
||||
# re-querying. A place with 30 primaries and 2 secondaries publishes a
|
||||
# primary variant and no secondary one.
|
||||
phase_urns: dict[str, tuple[int, ...]] = field(default_factory=dict)
|
||||
|
||||
def publishes_phase(self, phase: str) -> bool:
|
||||
return len(self.phase_urns.get(phase, ())) >= MIN_SCHOOLS
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.kind}:{self.slug}"
|
||||
|
||||
|
||||
def _publishable_urns(df) -> set[int]:
|
||||
"""URNs with something a page could state, deduplicated across years."""
|
||||
from backend.app import _PUBLISHABLE_FIELDS
|
||||
|
||||
cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
|
||||
if not cols:
|
||||
return set()
|
||||
return set(df.loc[df[cols].notna().any(axis=1), "urn"].astype(int))
|
||||
|
||||
|
||||
def _phase_urns(group, publishable: set[int]) -> dict[str, tuple[int, ...]]:
|
||||
"""URNs per phase. All-through schools count toward both, matching the
|
||||
PHASE_GROUPS mapping the search filters already use."""
|
||||
from backend.app import PHASE_GROUPS
|
||||
|
||||
if "phase" not in group.columns:
|
||||
return {}
|
||||
lowered = group["phase"].fillna("").str.lower()
|
||||
out: dict[str, tuple[int, ...]] = {}
|
||||
for phase in ("primary", "secondary"):
|
||||
wanted = PHASE_GROUPS.get(phase, set())
|
||||
subset = group[lowered.isin(wanted)]
|
||||
urns = tuple(sorted({int(u) for u in subset["urn"]} & publishable))
|
||||
if urns:
|
||||
out[phase] = urns
|
||||
return out
|
||||
|
||||
|
||||
def _parent_authority(group) -> str | None:
|
||||
"""The most common authority in a group — the useful 301 target.
|
||||
|
||||
A town spanning several authorities has no single parent, so the mode is
|
||||
the honest answer rather than an arbitrary first row.
|
||||
"""
|
||||
if "local_authority" not in group.columns:
|
||||
return None
|
||||
top = group["local_authority"].dropna()
|
||||
return str(top.mode().iloc[0]) if not top.empty else None
|
||||
|
||||
|
||||
def _group(df, column: str, kind: str, publishable: set[int]) -> dict[str, Place]:
|
||||
"""One Place per distinct value of `column` that clears the threshold."""
|
||||
from backend.app import _slugify
|
||||
|
||||
if column not in df.columns:
|
||||
return {}
|
||||
|
||||
out: dict[str, Place] = {}
|
||||
for name, group in df.groupby(column, dropna=True):
|
||||
name = str(name).strip()
|
||||
if not name:
|
||||
continue
|
||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||
if len(urns) < MIN_SCHOOLS:
|
||||
continue
|
||||
slug = _slugify(name)
|
||||
if not slug:
|
||||
continue
|
||||
place = Place(
|
||||
kind=kind, slug=slug, name=name, urns=urns,
|
||||
parent_authority=_parent_authority(group) if kind == "town" else None,
|
||||
phase_urns=_phase_urns(group, publishable),
|
||||
)
|
||||
out[place.key] = place
|
||||
return out
|
||||
|
||||
|
||||
# "SW11 2AA" -> "SW11". Two letters max, one or two digits, optional letter.
|
||||
_OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d{1,2}[A-Z]?)\s")
|
||||
|
||||
|
||||
def _outcode(postcode) -> str | None:
|
||||
if not isinstance(postcode, str):
|
||||
return None
|
||||
m = _OUTCODE_RE.match(postcode.upper().strip())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _outcode_places(df, publishable: set[int]) -> dict[str, Place]:
|
||||
"""One Place per postcode district clearing the threshold.
|
||||
|
||||
These carry no phase variants: nobody searches "primary schools in SW11".
|
||||
"""
|
||||
if "postcode" not in df.columns:
|
||||
return {}
|
||||
working = df.assign(_oc=df["postcode"].map(_outcode))
|
||||
working = working[working["_oc"].notna()]
|
||||
|
||||
out: dict[str, Place] = {}
|
||||
for oc, group in working.groupby("_oc"):
|
||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||
if len(urns) < MIN_SCHOOLS:
|
||||
continue
|
||||
place = Place(kind="outcode", slug=str(oc).lower(), name=str(oc),
|
||||
urns=urns, parent_authority=_parent_authority(group),
|
||||
phase_urns=_phase_urns(group, publishable))
|
||||
out[place.key] = place
|
||||
return out
|
||||
|
||||
|
||||
def _locality_places(df, publishable: set[int],
|
||||
town_slugs: set[str]) -> dict[str, Place]:
|
||||
"""One Place per curated locality clearing the threshold."""
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
if "postcode" not in df.columns:
|
||||
return {}
|
||||
working = df.assign(_oc=df["postcode"].map(_outcode))
|
||||
|
||||
out: dict[str, Place] = {}
|
||||
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
|
||||
if slug in town_slugs:
|
||||
# Skip, do not raise. The guard exists so a locality never
|
||||
# silently shadows a town — skipping achieves that, and the error
|
||||
# log makes it loud.
|
||||
#
|
||||
# Raising here took down sitemap generation for all 25,000 school
|
||||
# pages when "richmond" met the GIAS town Richmond in North
|
||||
# Yorkshire. Worse, GIAS town names change without any code change,
|
||||
# so a raise means curated data can break the site spontaneously.
|
||||
# A curation mistake must cost one page, not the sitemap.
|
||||
logger.error(
|
||||
"locality %r collides with the published town of the same "
|
||||
"slug and has been skipped; rename it or remove it", slug)
|
||||
continue
|
||||
group = working[working["_oc"].isin(outcodes)]
|
||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
||||
if len(urns) < MIN_SCHOOLS:
|
||||
# Not an error — a locality can legitimately be too small. Logged
|
||||
# because one you meant to publish quietly vanishing is the
|
||||
# failure worth hearing about.
|
||||
logger.warning(
|
||||
"locality %s (%s) has %d publishable schools, below the "
|
||||
"threshold of %d - not published",
|
||||
slug, ", ".join(outcodes), len(urns), MIN_SCHOOLS)
|
||||
continue
|
||||
place = Place(kind="locality", slug=slug, name=name, urns=urns,
|
||||
parent_authority=_parent_authority(group),
|
||||
phase_urns=_phase_urns(group, publishable))
|
||||
out[place.key] = place
|
||||
return out
|
||||
|
||||
|
||||
def build_place_registry(df) -> dict[str, Place]:
|
||||
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
|
||||
if df.empty or "urn" not in df.columns:
|
||||
return {}
|
||||
|
||||
publishable = _publishable_urns(df)
|
||||
registry: dict[str, Place] = {}
|
||||
registry.update(_group(df, "local_authority", "authority", publishable))
|
||||
|
||||
towns = _group(df, "town", "town", publishable)
|
||||
registry.update(towns)
|
||||
|
||||
town_slugs = {p.slug for p in towns.values()}
|
||||
registry.update(_locality_places(df, publishable, town_slugs))
|
||||
registry.update(_outcode_places(df, publishable))
|
||||
return registry
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Tests for the place registry (spec 2026-08-21).
|
||||
|
||||
The registry is built from the in-memory school DataFrame, so these build a
|
||||
small frame directly rather than touching a database.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from backend.places import MIN_SCHOOLS, build_place_registry
|
||||
|
||||
|
||||
def _df(rows: list[dict]) -> pd.DataFrame:
|
||||
base = {
|
||||
"year": 202425, "ofsted_grade": 2.0, "ofsted_date": None,
|
||||
"rwm_expected_pct": 60.0, "attainment_8_score": np.nan,
|
||||
"phase": "Primary", "postcode": "AA1 1AA",
|
||||
}
|
||||
return pd.DataFrame([{**base, **r} for r in rows])
|
||||
|
||||
|
||||
def _town(n: int, town: str, la: str, start: int = 100000, **kw) -> list[dict]:
|
||||
"""`start` offsets the URNs so two calls can describe different schools —
|
||||
the Bedford case needs two authorities' worth of distinct URNs in one
|
||||
town."""
|
||||
return [
|
||||
{"urn": start + i, "school_name": f"{town} School {i}",
|
||||
"town": town, "local_authority": la, **kw}
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def test_town_clearing_the_threshold_is_published():
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
|
||||
assert "town:brentwood" in reg
|
||||
assert reg["town:brentwood"].name == "Brentwood"
|
||||
assert len(reg["town:brentwood"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_town_below_the_threshold_is_not_published():
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS - 1, "Crosby", "Sefton")))
|
||||
assert "town:crosby" not in reg
|
||||
|
||||
|
||||
def test_a_town_below_threshold_still_names_its_authority():
|
||||
# The route layer needs somewhere to 301 to.
|
||||
reg = build_place_registry(_df(
|
||||
_town(MIN_SCHOOLS - 1, "Crosby", "Sefton") + _town(MIN_SCHOOLS, "Bootle", "Sefton")))
|
||||
assert "authority:sefton" in reg
|
||||
|
||||
|
||||
def test_town_and_authority_of_the_same_name_are_separate_places():
|
||||
# 67 real collisions. Neither set contains the other: Bedford the town has
|
||||
# 104 schools, Bedford the authority 86, because postal towns cross
|
||||
# authority boundaries.
|
||||
rows = (_town(MIN_SCHOOLS, "Bedford", "Bedford")
|
||||
+ _town(MIN_SCHOOLS, "Bedford", "Central Bedfordshire", start=200000))
|
||||
reg = build_place_registry(_df(rows))
|
||||
town, authority = reg["town:bedford"], reg["authority:bedford"]
|
||||
assert set(town.urns) != set(authority.urns)
|
||||
assert len(town.urns) == MIN_SCHOOLS * 2 # both authorities' schools
|
||||
assert len(authority.urns) == MIN_SCHOOLS # only this authority's
|
||||
|
||||
|
||||
def test_schools_without_publishable_data_do_not_count_toward_the_threshold():
|
||||
rows = _town(MIN_SCHOOLS, "Ghosttown", "Nowhere")
|
||||
for r in rows:
|
||||
r["rwm_expected_pct"] = np.nan
|
||||
r["ofsted_grade"] = np.nan
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert "town:ghosttown" not in reg
|
||||
|
||||
|
||||
def test_blank_town_is_ignored():
|
||||
rows = _town(MIN_SCHOOLS, "", "Essex")
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert not any(k.startswith("town:") for k in reg)
|
||||
|
||||
|
||||
def test_a_school_is_counted_once_even_with_several_years_of_rows():
|
||||
rows = []
|
||||
for year in (202324, 202425):
|
||||
rows += [{**r, "year": year} for r in _town(MIN_SCHOOLS, "Beccles", "Suffolk")]
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert len(reg["town:beccles"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_locality_groups_schools_by_outcode(monkeypatch):
|
||||
# The GIAS town field collapses 1,819 London schools into "London", so a
|
||||
# locality is defined by its postcode districts instead.
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"battersea": ("Battersea", ("SW11",))})
|
||||
rows = _town(MIN_SCHOOLS, "London", "Wandsworth")
|
||||
for r in rows:
|
||||
r["postcode"] = "SW11 2AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["locality:battersea"].name == "Battersea"
|
||||
assert len(reg["locality:battersea"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_locality_below_the_threshold_is_not_published(monkeypatch):
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"nowhere": ("Nowhere", ("ZZ99",))})
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "London", "Wandsworth")))
|
||||
assert "locality:nowhere" not in reg
|
||||
|
||||
|
||||
def test_a_locality_may_not_shadow_a_viable_town(monkeypatch, caplog):
|
||||
"""A colliding locality is skipped loudly, and the town survives.
|
||||
|
||||
This used to raise, which took down sitemap generation for all 25,000
|
||||
school pages the first time a curated slug met a real GIAS town. Curated
|
||||
data must not be able to break the site — and GIAS town names change with
|
||||
no code change at all, so the raise could fire spontaneously.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"brentwood": ("Brentwood", ("CM13",))})
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "CM13 1AA"
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
reg = build_place_registry(_df(rows))
|
||||
|
||||
assert "locality:brentwood" not in reg # skipped
|
||||
assert "town:brentwood" in reg # the town is untouched
|
||||
assert "brentwood" in caplog.text # and it was loud about it
|
||||
|
||||
|
||||
def test_a_locality_collision_does_not_break_the_rest_of_the_registry(monkeypatch):
|
||||
# The whole point of skipping rather than raising.
|
||||
from backend import localities
|
||||
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
|
||||
{"brentwood": ("Brentwood", ("CM13",))})
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "CM13 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert "authority:essex" in reg
|
||||
assert "outcode:cm13" in reg
|
||||
|
||||
|
||||
def test_outcode_places_are_built_from_postcodes():
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "CM13 1AA"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert reg["outcode:cm13"].name == "CM13"
|
||||
assert len(reg["outcode:cm13"].urns) == MIN_SCHOOLS
|
||||
|
||||
|
||||
def test_malformed_postcodes_do_not_create_places():
|
||||
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
for r in rows:
|
||||
r["postcode"] = "not a postcode"
|
||||
reg = build_place_registry(_df(rows))
|
||||
assert not any(k.startswith("outcode:") for k in reg)
|
||||
|
||||
|
||||
def test_every_curated_locality_is_structurally_valid():
|
||||
# Guards the hand-maintained file: real slug, real name, real outcodes.
|
||||
import re
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
assert LOCALITY_OUTCODES, "the curated locality list must not be empty"
|
||||
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
|
||||
assert re.fullmatch(r"[a-z0-9-]+", slug), slug
|
||||
assert name.strip() == name and name, slug
|
||||
assert outcodes, f"{slug} has no outcodes"
|
||||
for oc in outcodes:
|
||||
assert re.fullmatch(r"[A-Z]{1,2}\d{1,2}[A-Z]?", oc), (slug, oc)
|
||||
|
||||
|
||||
def test_the_pipeline_seed_mirrors_the_canonical_module():
|
||||
"""Two copies with no drift guard is worse than one copy.
|
||||
|
||||
backend/localities.py is canonical because the backend image does not
|
||||
contain pipeline/. The seed exists so the warehouse can join on the same
|
||||
definitions, and this is what stops the two diverging — the same
|
||||
arrangement assert_gias_code_names_match_seed.sql gives gias_codes.
|
||||
"""
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
seed_path = (Path(__file__).resolve().parents[2]
|
||||
/ "pipeline/transform/seeds/locality_outcodes.csv")
|
||||
assert seed_path.exists(), f"missing seed mirror at {seed_path}"
|
||||
|
||||
seed = {
|
||||
row["locality_slug"]: (row["locality_name"],
|
||||
tuple(row["outcodes"].split("|")))
|
||||
for row in csv.DictReader(seed_path.open())
|
||||
}
|
||||
assert seed == LOCALITY_OUTCODES
|
||||
|
||||
|
||||
def test_no_curated_locality_names_a_london_borough():
|
||||
"""Boroughs are authorities and already have a page.
|
||||
|
||||
A locality defined by two or three outcodes inside a borough would be a
|
||||
partial, near-duplicate subset of that authority page — the exact
|
||||
thin-content failure the two-namespace design exists to avoid. Hackney,
|
||||
Islington, Greenwich and Ealing were all in the first draft.
|
||||
|
||||
Hardcoded rather than read from the corpus because this must fail in CI,
|
||||
where there is no database.
|
||||
"""
|
||||
from backend.localities import LOCALITY_OUTCODES
|
||||
|
||||
boroughs = {
|
||||
"barking-and-dagenham", "barnet", "bexley", "brent", "bromley",
|
||||
"camden", "croydon", "ealing", "enfield", "greenwich", "hackney",
|
||||
"hammersmith-and-fulham", "haringey", "harrow", "havering",
|
||||
"hillingdon", "hounslow", "islington", "kensington-and-chelsea",
|
||||
"kingston-upon-thames", "lambeth", "lewisham", "merton", "newham",
|
||||
"redbridge", "richmond-upon-thames", "southwark", "sutton",
|
||||
"tower-hamlets", "waltham-forest", "wandsworth", "westminster",
|
||||
}
|
||||
named = boroughs & set(LOCALITY_OUTCODES)
|
||||
assert not named, (
|
||||
f"these are boroughs, not districts: {sorted(named)} - they already "
|
||||
"have an authority page covering every school"
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for the places API (spec 2026-08-21)."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _schools_df() -> pd.DataFrame:
|
||||
base = {
|
||||
"local_authority": "Essex", "school_type": "Academy",
|
||||
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
|
||||
"ofsted_date": None, "attainment_8_score": np.nan,
|
||||
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
|
||||
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
|
||||
}
|
||||
return pd.DataFrame([
|
||||
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
|
||||
"rwm_expected_pct": 50.0 + i}
|
||||
for i in range(6)
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_registry_lists_each_published_place(client):
|
||||
body = client.get("/api/places").json()
|
||||
slugs = {(p["kind"], p["slug"]) for p in body["places"]}
|
||||
assert ("town", "brentwood") in slugs
|
||||
assert ("authority", "essex") in slugs
|
||||
assert ("outcode", "cm13") in slugs
|
||||
|
||||
|
||||
def test_registry_carries_a_count_per_place(client):
|
||||
body = client.get("/api/places").json()
|
||||
town = next(p for p in body["places"] if p["slug"] == "brentwood")
|
||||
assert town["count"] == 6
|
||||
|
||||
|
||||
def test_place_detail_returns_its_schools_ranked(client):
|
||||
body = client.get("/api/places/town/brentwood").json()
|
||||
assert body["place"]["name"] == "Brentwood"
|
||||
scores = [s["rwm_expected_pct"] for s in body["schools"]]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
|
||||
def test_place_detail_carries_the_local_average(client):
|
||||
body = client.get("/api/places/town/brentwood").json()
|
||||
# 50..55 inclusive
|
||||
assert body["averages"]["rwm_expected_pct"] == pytest.approx(52.5)
|
||||
|
||||
|
||||
def test_phase_filter_narrows_the_school_list(client):
|
||||
body = client.get("/api/places/town/brentwood?phase=secondary").json()
|
||||
assert body["schools"] == []
|
||||
|
||||
|
||||
def test_unknown_place_404s(client):
|
||||
assert client.get("/api/places/town/atlantis").status_code == 404
|
||||
|
||||
|
||||
def test_unknown_kind_404s(client):
|
||||
assert client.get("/api/places/planet/mars").status_code == 404
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Tests for sitemap generation (spec 2026-08-20, workstream W1).
|
||||
|
||||
The sitemap is built from the in-memory school DataFrame, so these inject a
|
||||
small frame via monkeypatch rather than touching a database.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
|
||||
def _schools_df() -> pd.DataFrame:
|
||||
"""Two schools: one with results, one with neither results nor Ofsted."""
|
||||
base = {
|
||||
"local_authority": "Testshire",
|
||||
"school_type": "Academy",
|
||||
"phase": "Primary",
|
||||
"year": 202425,
|
||||
"ofsted_date": None,
|
||||
}
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{**base, "urn": 100001, "school_name": "Alpha Primary",
|
||||
"rwm_expected_pct": 62.0, "attainment_8_score": np.nan,
|
||||
"ofsted_grade": 2.0},
|
||||
{**base, "urn": 100002, "school_name": "Ghost Primary",
|
||||
"rwm_expected_pct": np.nan, "attainment_8_score": np.nan,
|
||||
"ofsted_grade": np.nan},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sitemap(monkeypatch) -> str:
|
||||
"""The sitemap index."""
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
return app_module.build_sitemap()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def schools_child(monkeypatch) -> str:
|
||||
"""The first school child sitemap, where school URLs actually live."""
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
return app_module.build_sitemaps()["schools-1.xml"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def static_child(monkeypatch) -> str:
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
return app_module.build_sitemaps()["static.xml"]
|
||||
|
||||
|
||||
def test_every_loc_uses_the_www_host(sitemaps):
|
||||
# The apex 301s to www. A <loc> that redirects burns a crawl per URL.
|
||||
# Checked across every file, index included, not just one.
|
||||
#
|
||||
# A child can legitimately be empty — this fixture holds two schools and no
|
||||
# town clearing the threshold — so the presence check applies only to files
|
||||
# that carry URLs. The absence check applies to all of them.
|
||||
for name, xml in sitemaps.items():
|
||||
assert "https://schoolcompare.co.uk" not in xml, name
|
||||
if "<loc>" in xml:
|
||||
assert "https://www.schoolcompare.co.uk" in xml, name
|
||||
|
||||
|
||||
def test_school_with_results_is_listed(schools_child):
|
||||
assert "/school/100001-alpha-primary" in schools_child
|
||||
|
||||
|
||||
def test_school_with_no_results_and_no_ofsted_is_omitted(schools_child):
|
||||
# Nothing for a search result to say about it. Submitting it spends crawl
|
||||
# budget and drags the corpus-wide quality signal down.
|
||||
#
|
||||
# Asserted against the child, not the index: the index carries no school
|
||||
# URLs at all, so it would pass this trivially and prove nothing.
|
||||
assert "/school/100002" not in schools_child
|
||||
|
||||
|
||||
def test_no_invented_priority_or_changefreq(sitemaps):
|
||||
# Google ignores both. They were noise dressed as signal.
|
||||
for name, xml in sitemaps.items():
|
||||
assert "<priority>" not in xml, name
|
||||
assert "<changefreq>" not in xml, name
|
||||
|
||||
|
||||
def test_ofsted_date_becomes_lastmod(monkeypatch):
|
||||
from backend import app as app_module
|
||||
import datetime
|
||||
|
||||
def _df():
|
||||
base = _schools_df()
|
||||
base.loc[base["urn"] == 100001, "ofsted_date"] = datetime.date(2024, 3, 14)
|
||||
return base
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _df)
|
||||
xml = app_module.build_sitemaps()["schools-1.xml"]
|
||||
assert "<lastmod>2024-03-14</lastmod>" in xml
|
||||
|
||||
|
||||
def test_no_lastmod_invented_when_date_unknown(monkeypatch):
|
||||
# An always-now lastmod is a claim Google learns to distrust. Absent
|
||||
# honestly means unknown.
|
||||
from backend import app as app_module
|
||||
|
||||
def _df():
|
||||
df = _schools_df()
|
||||
df["ofsted_date"] = None
|
||||
return df
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _df)
|
||||
# The child only. The index legitimately carries a lastmod, because there
|
||||
# it means "when this sitemap file changed", which we do know.
|
||||
xml = app_module.build_sitemaps()["schools-1.xml"]
|
||||
assert "<lastmod>" not in xml
|
||||
|
||||
|
||||
def test_static_routes_are_listed(static_child):
|
||||
for path in ("/", "/rankings", "/compare", "/admissions"):
|
||||
assert f"<loc>https://www.schoolcompare.co.uk{path}</loc>" in static_child
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sitemaps(monkeypatch) -> dict:
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
||||
return app_module.build_sitemaps()
|
||||
|
||||
|
||||
def test_index_lists_each_child(sitemaps):
|
||||
index = sitemaps["sitemap.xml"]
|
||||
assert "<sitemapindex" in index
|
||||
assert "https://www.schoolcompare.co.uk/sitemaps/static.xml" in index
|
||||
assert "https://www.schoolcompare.co.uk/sitemaps/schools-1.xml" in index
|
||||
|
||||
|
||||
def test_index_carries_no_url_elements(sitemaps):
|
||||
# A sitemap index holds <sitemap> entries only; mixing in <url> is invalid.
|
||||
assert "<url>" not in sitemaps["sitemap.xml"]
|
||||
|
||||
|
||||
def test_index_does_not_list_itself(sitemaps):
|
||||
assert "<loc>https://www.schoolcompare.co.uk/sitemap.xml</loc>" not in sitemaps["sitemap.xml"]
|
||||
|
||||
|
||||
def test_static_child_holds_the_static_routes(sitemaps):
|
||||
static = sitemaps["static.xml"]
|
||||
for path in ("/", "/rankings", "/compare", "/admissions"):
|
||||
assert f"<loc>https://www.schoolcompare.co.uk{path}</loc>" in static
|
||||
|
||||
|
||||
def test_school_child_holds_the_schools(sitemaps):
|
||||
assert "/school/100001-alpha-primary" in sitemaps["schools-1.xml"]
|
||||
|
||||
|
||||
def test_children_are_chunked_under_the_limit(monkeypatch):
|
||||
# Sitemaps cap at 50,000 URLs per file. Chunk at 10,000 so a child stays
|
||||
# small enough to eyeball in Search Console.
|
||||
from backend import app as app_module
|
||||
import pandas as _pd
|
||||
|
||||
rows = [
|
||||
{"urn": 200000 + i, "school_name": f"School {i}", "year": 202425,
|
||||
"rwm_expected_pct": 60.0, "attainment_8_score": None,
|
||||
"ofsted_grade": 2.0, "ofsted_date": None}
|
||||
for i in range(10_001)
|
||||
]
|
||||
monkeypatch.setattr(app_module, "load_school_data", lambda: _pd.DataFrame(rows))
|
||||
|
||||
maps = app_module.build_sitemaps()
|
||||
assert maps["schools-1.xml"].count("<url>") == 10_000
|
||||
assert maps["schools-2.xml"].count("<url>") == 1
|
||||
|
||||
|
||||
def test_build_sitemap_still_returns_the_index(sitemap):
|
||||
# lifespan and the admin endpoint call build_sitemap(); keep it working.
|
||||
assert "<sitemapindex" in sitemap
|
||||
|
||||
|
||||
def test_school_with_results_in_an_earlier_year_is_still_listed(monkeypatch):
|
||||
"""Regression: The Mallard Academy (150367).
|
||||
|
||||
Real KS2 results 2015-16 to 2018-19, then null rows from 2022-23 onward
|
||||
because the school stopped reporting. The first cut tested the latest
|
||||
year's row alone and dropped it, along with ~220 others, even though its
|
||||
detail page shows all four years of results.
|
||||
"""
|
||||
from backend import app as app_module
|
||||
import pandas as _pd
|
||||
|
||||
base = {"local_authority": "Testshire", "school_type": "Academy",
|
||||
"phase": "Primary", "ofsted_date": None, "ofsted_grade": np.nan,
|
||||
"attainment_8_score": np.nan, "urn": 150367,
|
||||
"school_name": "Mallard Academy"}
|
||||
df = _pd.DataFrame([
|
||||
{**base, "year": 201819, "rwm_expected_pct": 67.0},
|
||||
{**base, "year": 202324, "rwm_expected_pct": np.nan},
|
||||
{**base, "year": 202425, "rwm_expected_pct": np.nan},
|
||||
])
|
||||
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
|
||||
|
||||
xml = app_module.build_sitemaps()["schools-1.xml"]
|
||||
assert "/school/150367-mallard-academy" in xml
|
||||
|
||||
|
||||
def test_school_with_no_results_in_any_year_is_still_omitted(monkeypatch):
|
||||
"""The fix must not turn into "list everything"."""
|
||||
from backend import app as app_module
|
||||
import pandas as _pd
|
||||
|
||||
base = {"local_authority": "Testshire", "school_type": "Academy",
|
||||
"phase": "Primary", "ofsted_date": None, "ofsted_grade": np.nan,
|
||||
"attainment_8_score": np.nan, "rwm_expected_pct": np.nan,
|
||||
"urn": 100002, "school_name": "Ghost Primary"}
|
||||
df = _pd.DataFrame([{**base, "year": y} for y in (202324, 202425)])
|
||||
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
|
||||
|
||||
assert "/school/100002" not in app_module.build_sitemaps()["schools-1.xml"]
|
||||
|
||||
|
||||
def _places_df() -> pd.DataFrame:
|
||||
base = {
|
||||
"local_authority": "Essex", "school_type": "Academy",
|
||||
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
|
||||
"ofsted_date": None, "attainment_8_score": np.nan,
|
||||
"town": "Brentwood", "postcode": "CM13 1AA",
|
||||
}
|
||||
return pd.DataFrame([
|
||||
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
|
||||
"rwm_expected_pct": 60.0}
|
||||
for i in range(6)
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def place_sitemaps(monkeypatch) -> dict:
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _places_df)
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
return app_module.build_sitemaps()
|
||||
|
||||
|
||||
def test_place_children_are_listed_in_the_index(place_sitemaps):
|
||||
index = place_sitemaps["sitemap.xml"]
|
||||
assert "/sitemaps/places-1.xml" in index
|
||||
assert "/sitemaps/outcodes-1.xml" in index
|
||||
|
||||
|
||||
def test_town_and_authority_urls_use_their_own_namespaces(place_sitemaps):
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood</loc>" in xml
|
||||
assert "<loc>https://www.schoolcompare.co.uk/schools/authority/essex</loc>" in xml
|
||||
|
||||
|
||||
def test_outcode_urls_live_in_their_own_child(place_sitemaps):
|
||||
assert "/schools/near/cm13" in place_sitemaps["outcodes-1.xml"]
|
||||
assert "/schools/near/cm13" not in place_sitemaps["places-1.xml"]
|
||||
|
||||
|
||||
def test_place_urls_carry_no_priority_or_changefreq(place_sitemaps):
|
||||
for name in ("places-1.xml", "outcodes-1.xml"):
|
||||
assert "<priority>" not in place_sitemaps[name]
|
||||
assert "<changefreq>" not in place_sitemaps[name]
|
||||
|
||||
|
||||
def test_phase_variants_are_submitted_where_the_phase_clears_the_threshold(place_sitemaps):
|
||||
# "primary schools in beccles" is the query shape the baseline showed, so
|
||||
# each variant is its own page and has to be submitted. Emitting only the
|
||||
# bare place URL left ~950 of them reachable by nothing.
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood/primary</loc>" in xml
|
||||
|
||||
|
||||
def test_a_phase_below_its_own_threshold_is_not_submitted(place_sitemaps):
|
||||
# The fixture is six primaries and no secondaries.
|
||||
xml = place_sitemaps["places-1.xml"]
|
||||
assert "/schools/brentwood/secondary" not in xml
|
||||
|
||||
|
||||
def test_outcodes_get_no_phase_variants(place_sitemaps):
|
||||
# Nobody searches "primary schools in CM13"; the routes do not exist.
|
||||
xml = place_sitemaps["outcodes-1.xml"]
|
||||
assert "/primary" not in xml and "/secondary" not in xml
|
||||
@@ -8,8 +8,31 @@ from backend import data_loader
|
||||
from backend.data_loader import get_supplementary_data_batch
|
||||
|
||||
|
||||
def _sort_key(criterion):
|
||||
"""(column name, descending) for a SQLAlchemy order_by argument.
|
||||
|
||||
A bare column (Model.year) arrives as an InstrumentedAttribute carrying
|
||||
.key; Model.year.desc() wraps it in a UnaryExpression whose column sits on
|
||||
.element.
|
||||
"""
|
||||
name = getattr(criterion, "key", None)
|
||||
if name is not None:
|
||||
return name, False
|
||||
element = getattr(criterion, "element", None)
|
||||
name = getattr(element, "key", None)
|
||||
return name, "DESC" in str(criterion).upper()
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
"""Records that a query ran and serves canned rows filtered by an in-list."""
|
||||
"""Records that a query ran and serves canned rows filtered by an in-list.
|
||||
|
||||
order_by is honoured rather than ignored. The batch loader picks a row per
|
||||
URN by position — first for "latest Ofsted", last for "latest cut-off
|
||||
distance" — which is only correct because the database returned them
|
||||
sorted. A double that drops the ORDER BY makes those picks depend on
|
||||
fixture insertion order instead, so the test would pass with the sort
|
||||
reversed or removed and prove nothing about the query.
|
||||
"""
|
||||
|
||||
def __init__(self, recorder, model_name, rows):
|
||||
self._rec = recorder
|
||||
@@ -19,7 +42,20 @@ class _FakeQuery:
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
def order_by(self, *criteria):
|
||||
for crit in reversed(criteria): # reversed = stable multi-key sort
|
||||
name, descending = _sort_key(crit)
|
||||
if not name:
|
||||
continue
|
||||
values = [getattr(r, name, None) for r in self._rows]
|
||||
# Only sort on plainly comparable values. Some fixtures stand dates
|
||||
# up as namespace objects, which raise on <; leaving those in their
|
||||
# given order matches what the real query would produce for them.
|
||||
if not all(isinstance(v, (int, float, str)) for v in values):
|
||||
continue
|
||||
self._rows = sorted(
|
||||
self._rows, key=lambda r: getattr(r, name), reverse=descending
|
||||
)
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
@@ -69,6 +105,13 @@ def _adm_row(urn, year):
|
||||
)
|
||||
|
||||
|
||||
def _dist_row(urn, year, distance_m, route_count=1):
|
||||
return types.SimpleNamespace(
|
||||
urn=urn, year=year, distance_m=distance_m, route_count=route_count,
|
||||
la_name="Camden", distance_unit_raw="miles", source_file="camden/guide.pdf",
|
||||
)
|
||||
|
||||
|
||||
def test_one_query_per_table_and_latest_row_per_urn():
|
||||
rows = {
|
||||
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
||||
@@ -78,6 +121,14 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
||||
_ofsted_row(2, "2021-06-01", 1),
|
||||
],
|
||||
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
||||
# URN 1 has three years of cut-offs; only the most recent is served.
|
||||
# Deliberately not in year order — the ordering is the query's job.
|
||||
"FactAdmissionDistance": [
|
||||
_dist_row(1, 2026, 529.47),
|
||||
_dist_row(1, 2024, 772.49),
|
||||
_dist_row(1, 2025, 1421.05),
|
||||
_dist_row(2, 2023, 2029.38, route_count=4),
|
||||
],
|
||||
"FactPupilCharacteristics": [],
|
||||
"FactDeprivation": [],
|
||||
"FactFinance": [],
|
||||
@@ -85,10 +136,10 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
||||
session = _FakeSession(rows)
|
||||
out = get_supplementary_data_batch(session, [1, 2])
|
||||
|
||||
# Exactly one query per table — five total, regardless of two URNs.
|
||||
# Exactly one query per table — six total, regardless of two URNs.
|
||||
assert sorted(session.queries) == [
|
||||
"FactAdmissions", "FactDeprivation", "FactFinance",
|
||||
"FactOfstedInspection", "FactPupilCharacteristics",
|
||||
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
|
||||
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
|
||||
]
|
||||
|
||||
# Latest Ofsted kept per URN
|
||||
@@ -100,6 +151,18 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
||||
assert out[1]["admissions"]["year"] == 202627
|
||||
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
||||
|
||||
# Cut-off distance: the latest year only. Earlier years stay in the mart
|
||||
# but are held back as a paid feature, and this API is public — serving
|
||||
# them here would hand them to anyone reading the response. The fixture
|
||||
# rows are deliberately out of order, so "latest" only comes out right if
|
||||
# the query's ORDER BY is doing the work.
|
||||
assert out[1]["admission_distance"]["year"] == 2026
|
||||
assert "admission_distance_history" not in out[1]
|
||||
assert out[1]["admission_distance"]["distance_m"] == 529.47
|
||||
# route_count travels with the figure — the page needs it to say the
|
||||
# distance is the furthest of several bands rather than the only one.
|
||||
assert out[2]["admission_distance"]["route_count"] == 4
|
||||
|
||||
# Empty tables degrade to the null block, not a crash
|
||||
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
||||
|
||||
@@ -109,3 +172,5 @@ def test_single_wrapper_matches_batch(monkeypatch):
|
||||
single = data_loader.get_supplementary_data(session, 5)
|
||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||
assert single["admissions_history"] == []
|
||||
assert single["admission_distance"] is None
|
||||
assert "admission_distance_history" not in single
|
||||
@@ -0,0 +1,85 @@
|
||||
# Remote branch cleanup, 2026-08-20
|
||||
# Restore any branch with: git push origin <sha>:refs/heads/<name>
|
||||
|
||||
## Deleted: fully merged into main (content is in main)
|
||||
75677f4252b759ef895e7d5f7c19f8f1745bdb59 add-contact-form-footer
|
||||
fa1abff642683dfd26ba88a295a0a6710d77147a chore/byline-removal-and-audit-figure
|
||||
95081d38bdf87764ef5d298676c25fae4cd3b792 chore/remove-parent-view
|
||||
6877abedebfc1c7d95f1f6ebe68945c62be528ab chore/staged-prod-promotion
|
||||
090d5f7bec824e083d3252e2c6e636686016304d ci/frontend-checks-speedup
|
||||
8c3a5cc4e9f551f0190d85357ce7741ad87f3a4c design/cohort-identity
|
||||
955659580067ca8b81bd77e31e4e2554103f094d feat/allow-analytics-iframe-embed
|
||||
6828f6cd4417284ea3eb6f088fa20945b8b40ed3 feat/compare-chips-two-per-row
|
||||
d5cd0abfee226885119665da5d2aa8288b59217f feat/compare-data-foundation
|
||||
6dd9b04b50bee146682da87efad8fc8b526251c5 feat/compare-frontend-rebuild
|
||||
96d5fcf5b07b6f175b48e9b20fcb765a320a907f feat/detail-header-details-reveal
|
||||
f1388ff5bd0af1409823a1e047b7ba84246a0f70 feat/gias-sixth-form-flag
|
||||
eddf74745f86c9c6d9eb07d867246ff9cc90dc20 feat/hero-artwork-v2
|
||||
3015c37bac6dc28db58c80fdb9235942c25b83d7 feat/hero-byline
|
||||
8e763e39d17a4964cf558e51c03f044186371f6d feat/info-popover-tooltip
|
||||
88c653215d520ab6e902c9de55bf27d86eeb90c3 feat/last-distance-offered
|
||||
a72323874f7aebdb2e64b6d64a5febd61152d09d feat/last-distance-offered-full
|
||||
c9a1892bfb0370e0672e5849cba294ddfabe3c65 feat/latest-cutoff-only
|
||||
4e8df006d75d8be2a1d8529ddf855c445854cba1 feat/near-me-by-search
|
||||
45ab479062c6a1639facad636fc0cc0cf0fd9155 feat/proposed-to-close-schools
|
||||
3bf2e8f262cbe058fda6de6f8ea3e050224a51b9 feat/school-detail-visualisations
|
||||
1f80571b1ff217dc92b660a936c02b5f49d07f0b feat/umami-heatmap-recorder
|
||||
609bb923d96aa5730131463ea35d5efdd404bf96 feature/ingest-independent-schools
|
||||
94151c58ea38a9256d66d15161293486a500c7a2 fix/admissions-section-height
|
||||
79246edc22961c2beb3520437e9d064d3b809d10 fix/annual-dag-ks4-national-selector
|
||||
59ac9c10b97e0ef1143f57fea06b324e72ac3d4a fix/chart-marker-contrast
|
||||
9f8dba227c95706ca3527bd48d381e7622cc0a5e fix/compare-chart-refetch-resilience
|
||||
e74d3882ce78a141fa1a57daa3102d7a58852dc3 fix/compare-expert-fixes
|
||||
80176cac4db4820e76ea2a156c7a2974bee2f203 fix/compare-final-review-mustfix
|
||||
f579630fab6c456e26a6984a3e8eebdbe3184202 fix/compare-mockup-drift
|
||||
dc85254ad2ddf134b4434065d4762cef374d2220 fix/compare-null-year-blanks-chart
|
||||
43a2c4a6bc539b621f31655aec05ef319a25f343 fix/compare-refresh-and-fetch
|
||||
d677b5453365b72c81d6df2de62b1fa0d05d684d fix/daily-dag-cache-invalidation
|
||||
f6bb037c471553e8195b5a8b147467ce0d07a688 fix/detail-all-through
|
||||
17bd4d5a5eb0b12ca79b97db587f14f7d671e85b fix/detail-chart-truthfulness
|
||||
4e6be0ce65647410b4ff74f8b763207c28920c26 fix/detail-inclusion-admissions
|
||||
fdda52ff0af3fae03a4b059a655973cdbb269f91 fix/detail-ofsted-correctness
|
||||
e36125b24aba254a8d15c5c33b4d2a296e691995 fix/detail-provenance-anchoring
|
||||
b31e71ac884df6507f567adc046c9fc52d9d310a fix/detail-report-card-render-date
|
||||
32f8a02862be6a1d49f4c3928b17fcf15d4c94cc fix/detail-trend-chart-taller
|
||||
e65688d600a86818fe21ae4c61ba27e5b6ec8d7c fix/e2e-brand-assertions
|
||||
3adea73ee04cdedfab54b0351878b297f72756ad fix/e2e-compare-chips-phase
|
||||
06e4898c30feaedc471f97aba28ddb0d61379f4d fix/e2e-compare-samephase
|
||||
9abd020967670a855e80fe5a908c8048a3aa9f14 fix/e2e-distance-locator
|
||||
acec8135e1ec7c9c3c255e5b23733a0ef862b590 fix/e2e-rankings-year-pick
|
||||
5944d88f0b1517ef1ef56af1b62270d2cf28e717 fix/expert-signoff-mustfixes
|
||||
2433101fa08be5df6f170d41790512ffe823d33e fix/font-cascade-and-map-palette
|
||||
74ca76d150deec6725259d9637ea86d7bb90c683 fix/gias-legacy-fallback
|
||||
bdaa05cd542f563ef74c45307cd8f7fc465193c9 fix/hero-fallback-and-sharp
|
||||
d52d384cf23d282b44e9251176f8f3402d808600 fix/hero-map-ios-fullscreen
|
||||
4043270a77bbe4edb18207fa5f1d94d4747fe12f fix/hero-mobile-and-wording
|
||||
22e9eb2d48b0d6623e88fd67cb6ca8e5e4074583 fix/homepage-education-accuracy
|
||||
8d50afef1e8a2b621b7344eadf475b0d609ad7a2 fix/leaflet-specificity-and-font-assertion
|
||||
b2b2cad5acf534ae7a667d3fb2be15efff37c4a7 fix/list-map-report-card-signal
|
||||
dc21e80a5e9eebd13aaab84735642eb77cef35e6 fix/mobile-cell-name-size
|
||||
e5f7f4c959f024c073472122d858333a0f24866c fix/mobile-compare-polish
|
||||
a00cbe916182d1e04661c750a1ce2e7ad27a68ae fix/mobile-sort-select-overflow
|
||||
2fd997bfe640c419a6713e85df008463de7f56e8 fix/modal-keyboard-viewport
|
||||
3e7705756776a0c44d27966dfc972023f1f38b69 fix/ofsted-link-text
|
||||
ce422e64363e2b03c186ef316832b6de15502d67 fix/promote-status-token
|
||||
4522cbf64560db1e1cd519e119aa466b42cd16a1 fix/proposed-to-close-copy
|
||||
15da060e4af37fbae919e0edf25f108266de2585 fix/rankings-admissions-accuracy
|
||||
6c872ce726f210433354ca38dc5314bc6a467534 fix/rankings-year-validation
|
||||
1c1df7796194af3d47f8e5ac0a0fbe6f323700e7 fix/report-card-chip-alignment
|
||||
b2dc4d0779ced02709429afaa5985acf4abda794 fix/results-map-ios-fullscreen
|
||||
95a5783da1fc994df76cb97238b55596dce4cd8f fix/runtime-api-proxy
|
||||
8a9ba30cc24e29653a72c03c0e817684b7db7c07 fix/sats-per-level-national
|
||||
536832a524fc4f9ed858f047e00b9a4d9d429c46 fix/school-detail-nan-500
|
||||
fef83b3bf244a9bf3cb4afa75dbc433d8725014f fix/schoolbar-sticky-offset
|
||||
b0c5b6bb57c879477da12c37b15cff950c29ebd3 fix/secondary-anchors-button-affordance
|
||||
261403bcd2b01aa4f26ee212e26302fc0f769bf9 fix/special-note-full-width
|
||||
ea5249a2ea6faf6bfa5a1522644387ba59770ec8 fix/standardise-distance-units
|
||||
e4565e9f158721d4df6b918f2b065de82851f8d9 fix/trends-chart-height
|
||||
3aad5101a842539105022f9059e85c224fc5973a fix/welsh-establishment-leak
|
||||
315f1feede70bdf3101d2fdd305b3d06e037fdac perf/batch-supplementary
|
||||
d2dc78aeb599e16b7ef5019be2b360b08df463bc perf/compare-loading
|
||||
e098ad4bd1130152705788d4773837b7d1e7112e perf/server-client-split
|
||||
|
||||
## Deleted: superseded by PR #110 (content preserved on feat/seo-crawl-hygiene-main)
|
||||
786ec80dd4de4a3cb674a89e35b3b5e461639289 feat/seo-crawl-hygiene
|
||||
a5ac0bcd1b37bc10dcbce88f8601d01bf7b3eaaf feat/england-only-corpus
|
||||
@@ -0,0 +1,950 @@
|
||||
# Detail-page Server/Client Split Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stop ~750 lines of static school-detail markup from shipping to the browser as JavaScript, by turning the page's sections into React Server Components behind a small client shell.
|
||||
|
||||
**Architecture:** A server component imported by a client component becomes a client component. So the sections cannot be children of a client `SchoolDetailView`. Instead `app/school/[slug]/page.tsx` (server) composes the sections and passes them *through* a small client shell as `children` — the pattern already used at `app/page.tsx:98-99`. The shell keeps the genuinely interactive chrome (back link, header reveal, hero map, compare CTA, sticky nav, scroll-spy); everything below it becomes server-rendered.
|
||||
|
||||
**Tech Stack:** Next.js 16.1.6 (App Router, Turbopack), React 19, TypeScript, CSS Modules, Jest + React Testing Library, Playwright (e2e).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-30-server-client-split-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Branch is `perf/server-client-split`, already created off `origin/main` at `cab7b4f`. Never push to `main`; this lands as a PR (CLAUDE.md SDLC).
|
||||
- All paths below are relative to the repo root. The Next.js app lives in `nextjs-app/`; run all `npm` commands from there.
|
||||
- **Do not start a local server to test** — it does not work in this environment (CLAUDE.md). Verification is `npm run typecheck`, `npm test`, `npm run build`.
|
||||
- **No visual or behavioural change** on any of the four school shapes, except the two documented CSS merges. No redesign, no copy changes.
|
||||
- Commits 6 and 7 are **pure moves**: JSX is relocated verbatim, changing only the `styles` import and prop plumbing. If you find a bug mid-move, record it in the PR description — do not fix it inline.
|
||||
- Characterization tests written in Task 2 must pass **unmodified** through Tasks 3-8. The only file permitted to change is `__tests__/support/renderSchoolDetail.tsx`.
|
||||
- End commit messages with `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
## What is shared and what is not
|
||||
|
||||
Measured similarity between the two views' sections (`difflib.SequenceMatcher` on stripped lines):
|
||||
|
||||
| Section | Primary lines | Secondary lines | Similarity | Decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Ofsted | 88 | 107 | 80% | **Shared** — `OfstedSection` |
|
||||
| Finances | 29 | 35 | 91% | **Shared** — `FinancesSection` |
|
||||
| History | 121 | 47 | 40% | Separate components |
|
||||
| Admissions | 89 | 55 | 14% | Separate components |
|
||||
|
||||
The spec's section-sharing table listed Admissions and History as shared; measurement shows they are not. **This costs no bytes** — the client-JS win comes from a section being a *server* component, not from being shared. Sharing is a maintainability bonus, taken only where it is cheap.
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create — `nextjs-app/lib/`**
|
||||
- `schoolSections.ts` — pure derived flags (`hasInclusionData`, `hasKS2Results`, `suppressKs2Comparison`, …) and `buildNavItems()`. No JSX, no React. Unit-testable in isolation; consumed by `page.tsx` and the sections.
|
||||
|
||||
**Create — `nextjs-app/components/school/`** (mirrors the existing `components/compare/` layout)
|
||||
- `schoolSections.module.css` — merged stylesheet (Task 5)
|
||||
- `sectionShared.tsx` — `Section`, `MetricGrid`, `MetricCard`, `StatRow` primitives, all server
|
||||
- `OfstedSection.tsx`, `FinancesSection.tsx` — shared, server
|
||||
- `ResultsSection.tsx`, `AdmissionsSection.tsx`, `InclusionSection.tsx`, `HistorySection.tsx`, `SchoolLifeSection.tsx`, `LocalAreaSection.tsx` — primary, server
|
||||
- `GcseSection.tsx`, `SecondaryAdmissionsSection.tsx`, `SecondaryHistorySection.tsx`, `WellbeingSection.tsx` — secondary, server
|
||||
- `AdmissionsViewToggle.tsx` — **the only new client component**
|
||||
- `PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx` — server composers
|
||||
- `SchoolDetailShell.tsx` + `SchoolDetailShell.module.css` — the client shell
|
||||
|
||||
**Create — `nextjs-app/__tests__/`**
|
||||
- `support/schoolFixtures.ts` — four fixtures: primary, secondary, all-through, special
|
||||
- `support/renderSchoolDetail.tsx` — the swappable render helper
|
||||
- `components/schoolDetail.characterization.test.tsx`
|
||||
- `lib/schoolSections.test.ts`
|
||||
|
||||
**Modify**
|
||||
- `nextjs-app/jest.setup.js` — `IntersectionObserver`, `window.scrollTo`
|
||||
- `nextjs-app/app/school/[slug]/page.tsx` — fetch national averages, compose sections
|
||||
- `nextjs-app/package.json` — drop `swr`
|
||||
- `e2e/tests/journeys.spec.ts` — detail-page assertions
|
||||
|
||||
**Deliberately untouched**
|
||||
- `components/Navigation.tsx` — genuinely needs client (`usePathname` plus the comparison count). No task.
|
||||
- `components/Footer.tsx` — already a server component; no `'use client'` directive. No task.
|
||||
- `app/layout.tsx` — unchanged. The shared ~172 KB baseline is out of scope; see Task 9 Step 4.
|
||||
|
||||
**Delete**
|
||||
- `nextjs-app/components/SchoolDetailView.tsx` + `.module.css`
|
||||
- `nextjs-app/components/SecondarySchoolDetailView.tsx` + `.module.css`
|
||||
- `nextjs-app/hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts`, `useSchoolDetails.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Test environment and fixtures
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/jest.setup.js`
|
||||
- Create: `nextjs-app/__tests__/support/schoolFixtures.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing
|
||||
- Produces: `primaryFixture`, `secondaryFixture`, `allThroughFixture`, `specialFixture` — each an object matching the prop shape of `SchoolDetailView` (`{ schoolInfo, yearlyData, absenceData, ofsted, census, admissions, admissionsHistory, deprivation, finance }`), plus `nationalAveragesFixture` typed `NationalAverages`.
|
||||
|
||||
- [ ] **Step 1: Add the missing jsdom globals**
|
||||
|
||||
jsdom implements neither API; both detail views use `IntersectionObserver` twice and `window.scrollTo` once. Append to `nextjs-app/jest.setup.js`:
|
||||
|
||||
```javascript
|
||||
// jsdom implements neither of these; the detail views use both.
|
||||
global.IntersectionObserver = class {
|
||||
constructor(callback) { this.callback = callback; }
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() { return []; }
|
||||
};
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the fixtures**
|
||||
|
||||
Create `nextjs-app/__tests__/support/schoolFixtures.ts`. Derive the exact field names from `nextjs-app/lib/types.ts` (`School`, `SchoolResult`, `OfstedInspection`, `SchoolCensus`, `SchoolAdmissions`, `SchoolDeprivation`, `SchoolFinance`, `NationalAverages`) — do not invent fields; TypeScript will reject them.
|
||||
|
||||
The four fixtures must differ in exactly these ways, because these are the branches the characterization tests exercise:
|
||||
|
||||
| Fixture | `schoolInfo.phase` | Results present | Ofsted | Admissions history | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `primaryFixture` | `'Primary'` | `rwm_expected_pct: 58` | legacy OEIF, `overall_effectiveness: 2` | 3 years with `first_preference_offer_pct` | drives the trend toggle |
|
||||
| `secondaryFixture` | `'Secondary'` | `attainment_8_score: 46.2`, `progress_8_score: 0.31` | Report Card (`rc_*` fields set) | 1 year only | no trend toggle |
|
||||
| `allThroughFixture` | `'All-through'` | **both** `rwm_expected_pct` and `attainment_8_score` | legacy OEIF | 2 years | KS2 content gated back on |
|
||||
| `specialFixture` | `'Primary'`, `school_type: 'Community special school'` | `rwm_expected_pct: 0`, reading/writing/maths all `0` | none (`null`) | none (`null`) | England comparison suppressed |
|
||||
|
||||
Every fixture needs `urn` in the valid 6-digit range, a `school_name`, and a `local_authority`. Give `primaryFixture` at least 3 entries in `yearlyData` so the history table and chart have data.
|
||||
|
||||
- [ ] **Step 3: Verify the fixtures typecheck**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck`
|
||||
Expected: PASS. Any error here means a fixture field does not exist on the real type — fix the fixture, never the type.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/jest.setup.js nextjs-app/__tests__/support/schoolFixtures.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test: add jsdom globals and school detail fixtures
|
||||
|
||||
jsdom provides neither IntersectionObserver nor scrollTo; both detail views
|
||||
use them. Adds four fixtures (primary, secondary, all-through, special)
|
||||
covering the branches the characterization tests will pin.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Characterization tests
|
||||
|
||||
This is the safety net. **It must be green before any production code moves.**
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/__tests__/support/renderSchoolDetail.tsx`
|
||||
- Create: `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: fixtures from Task 1.
|
||||
- Produces: `renderSchoolDetail(fixture, opts?)` and `renderSecondarySchoolDetail(fixture, opts?)`. These are the **only** functions the tests call to render. Task 7 rewrites their bodies; their signatures and every assertion stay frozen.
|
||||
|
||||
- [ ] **Step 1: Write the render helper against the CURRENT components**
|
||||
|
||||
Create `nextjs-app/__tests__/support/renderSchoolDetail.tsx`:
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* The single seam between the characterization tests and the component tree.
|
||||
*
|
||||
* Task 7 rewrites the bodies of these two functions to render the new
|
||||
* shell + server-sections composition. Nothing else in the test suite may
|
||||
* change — assertions passing unmodified across that rewrite is the proof
|
||||
* that behaviour was preserved.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
return render(<SchoolDetailView {...fixture} />);
|
||||
}
|
||||
|
||||
export function renderSecondarySchoolDetail(fixture: any) {
|
||||
return render(<SecondarySchoolDetailView {...fixture} />);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the characterization tests**
|
||||
|
||||
Create `nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx`. Mock the charts and analytics at the top — Chart.js needs a canvas jsdom does not provide, and `track()` fires on mount:
|
||||
|
||||
```tsx
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
track: jest.fn(),
|
||||
getNavigationSource: () => 'direct',
|
||||
}));
|
||||
jest.mock('@/components/PerformanceChart', () => ({
|
||||
PerformanceChart: () => <div data-testid="performance-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SatsChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sats-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/AdmissionsTrendChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="admissions-trend-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SchoolHeroMap', () => ({
|
||||
SchoolHeroMap: () => <div data-testid="hero-map" />,
|
||||
}));
|
||||
```
|
||||
|
||||
Then cover, using `renderSchoolDetail` / `renderSecondarySchoolDetail` only:
|
||||
|
||||
1. **Ofsted, legacy OEIF** (`primaryFixture`) — the grade label `Good` renders; the inspected date renders; the `reports.ofsted.gov.uk` link carries the fixture's URN in its `href`.
|
||||
2. **Ofsted, Report Card** (`secondaryFixture`) — heading reads `Ofsted Report Card`; the November 2025 disclaimer renders; each populated `rc_*` category renders its label and band.
|
||||
3. **KS2 results + England delta** (`primaryFixture`) — the RWM percentage renders; a delta versus the England average renders.
|
||||
4. **KS4 results** (`secondaryFixture`) — Attainment 8 and Progress 8 values render; EBacc rows render.
|
||||
5. **All-through** (`allThroughFixture`) — **both** a KS2 figure and an Attainment 8 figure render in the same document. This is the highest-value assertion in the file; it is the case most likely to break.
|
||||
6. **Special school** (`specialFixture`) — no England-comparison delta renders, and the special-school note renders. Guards the PR #70 regression.
|
||||
7. **Admissions toggle** (`primaryFixture`) — both buttons render; the trend view starts `hidden`; after `userEvent.click` on the trend button the trend view is visible and the year view is `hidden`. Assert on the `hidden` attribute, matching how the current code toggles.
|
||||
8. **Admissions without trend** (`secondaryFixture`, 1 year) — no toggle buttons render.
|
||||
9. **Nav items** (`primaryFixture`) — the expected section ids are present in the document; and for `specialFixture` (no ofsted, no admissions) those two are absent.
|
||||
|
||||
Use `getByText` / `queryByText` / `getByRole` — never snapshots, and never assert on CSS-module class names, which are hashed and will change.
|
||||
|
||||
- [ ] **Step 3: Run the tests and confirm they PASS**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolDetail.characterization`
|
||||
Expected: **PASS.** This is the inverse of normal TDD — these tests describe behaviour that already exists, so green means they correctly pin the current behaviour. If one fails, the test is wrong, not the component. Fix the test.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/__tests__/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test: pin school detail behaviour before refactor
|
||||
|
||||
Characterization tests covering Ofsted (both layouts), KS2/KS4 results,
|
||||
all-through dual rendering, special-school comparison suppression, and the
|
||||
admissions toggle. All rendering goes through renderSchoolDetail(), the one
|
||||
seam that the server/client split is allowed to change.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Move national averages to the server
|
||||
|
||||
The prerequisite for everything after it: `primaryAvg` / `secondaryAvg` feed the England deltas across most sections, and while they arrive via `useEffect` those sections cannot be server components.
|
||||
|
||||
**Files:**
|
||||
- Modify: `nextjs-app/app/school/[slug]/page.tsx`
|
||||
- Modify: `nextjs-app/components/SchoolDetailView.tsx:173-179`
|
||||
- Modify: `nextjs-app/components/SecondarySchoolDetailView.tsx:91-93`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `fetchNationalAverages()` from `@/lib/api` (already exists, `revalidate: 3600`).
|
||||
- Produces: both view components gain a required prop `nationalAvg: NationalAverages | null`.
|
||||
|
||||
- [ ] **Step 1: Fetch it in parallel in the page**
|
||||
|
||||
In `app/school/[slug]/page.tsx`, replace the lone `fetchSchoolDetails` call in the default export with a parallel pair. Import `fetchNationalAverages` alongside the existing imports. National averages must never break the page, so it degrades to `null`:
|
||||
|
||||
```tsx
|
||||
let data;
|
||||
let nationalAvg: NationalAverages | null = null;
|
||||
try {
|
||||
[data, nationalAvg] = await Promise.all([
|
||||
fetchSchoolDetails(urn),
|
||||
fetchNationalAverages().catch(() => null),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
```
|
||||
|
||||
Leave `generateMetadata`'s own `fetchSchoolDetails(urn)` call alone — Next.js memoises identical `fetch` calls within a render pass, so it does not cost a second request.
|
||||
|
||||
- [ ] **Step 2: Pass it to both views**
|
||||
|
||||
Add `nationalAvg={nationalAvg}` to both the `<SecondarySchoolDetailView>` and `<SchoolDetailView>` elements at the bottom of the page.
|
||||
|
||||
- [ ] **Step 3: Accept the prop and delete the useEffect**
|
||||
|
||||
In `components/SchoolDetailView.tsx`: add `nationalAvg: NationalAverages | null;` to `SchoolDetailViewProps`, add `nationalAvg` to the destructured parameters, and delete lines 172-179 — the `useState` and the `useEffect` that fetches `/api/national-averages`. The two lines that follow it stay exactly as they are:
|
||||
|
||||
```tsx
|
||||
const primaryAvg = nationalAvg?.primary ?? {};
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
```
|
||||
|
||||
Apply the identical change to `components/SecondarySchoolDetailView.tsx` at lines 91-93.
|
||||
|
||||
- [ ] **Step 4: Update the render helper to supply the prop**
|
||||
|
||||
In `__tests__/support/renderSchoolDetail.tsx`, spread `nationalAveragesFixture` in:
|
||||
|
||||
```tsx
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
return render(<SchoolDetailView {...fixture} nationalAvg={nationalAveragesFixture} />);
|
||||
}
|
||||
```
|
||||
|
||||
This is a permitted change — the helper is the designated seam. The test file itself must not change.
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test -- schoolDetail.characterization`
|
||||
Expected: both PASS, with the characterization test file unmodified.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/app/school nextjs-app/components/SchoolDetailView.tsx \
|
||||
nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/__tests__/support/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): fetch national averages on the server
|
||||
|
||||
Both detail views fetched /api/national-averages in a useEffect, so the
|
||||
England-comparison deltas popped in after hydration and the sections that
|
||||
use them could not become server components. The page now fetches it in
|
||||
parallel with the school details (backend-cached 1h) and passes it down.
|
||||
|
||||
Removes one client round-trip per detail page.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract the derived-flags module
|
||||
|
||||
Pulls the pure data-shape logic out of the components so `page.tsx` can compute `navItems` on the server without importing a client component.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/lib/schoolSections.ts`
|
||||
- Create: `nextjs-app/__tests__/lib/schoolSections.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: types from `@/lib/types`; `isSpecialSchool` from `@/lib/utils`.
|
||||
- Produces:
|
||||
- `type SchoolFlags = { isAllThrough: boolean; isSecondary: boolean; isPrimary: boolean; latestResults: SchoolResult | null; hasGenderSplit: boolean; hasInclusionData: boolean; hasSchoolLife: boolean; hasDeprivation: boolean; hasFinance: boolean; hasLocation: boolean; hasKS2Results: boolean; hasKS4Results: boolean; hasAnyResults: boolean; isSpecial: boolean; ks2Placeholder: boolean; suppressKs2Comparison: boolean; suppressKs4Comparison: boolean }`
|
||||
- `computeSchoolFlags(input): SchoolFlags` where `input` is `{ schoolInfo, yearlyData, absenceData, census, deprivation, finance }`
|
||||
- `type NavItem = { id: string; label: string }`
|
||||
- `buildNavItems(flags: SchoolFlags, opts: { ofsted: OfstedInspection | null; admissions: SchoolAdmissions | null; yearlyDataLength: number }): NavItem[]`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `nextjs-app/__tests__/lib/schoolSections.test.ts` covering the branches that matter:
|
||||
|
||||
```ts
|
||||
import { computeSchoolFlags, buildNavItems } from '@/lib/schoolSections';
|
||||
import { primaryFixture, allThroughFixture, specialFixture } from '../support/schoolFixtures';
|
||||
|
||||
describe('computeSchoolFlags', () => {
|
||||
it('treats an all-through school as both primary-capable and secondary', () => {
|
||||
const f = computeSchoolFlags(allThroughFixture);
|
||||
expect(f.isAllThrough).toBe(true);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the KS2 comparison for a special school', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressKs2Comparison).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an all-zero KS2 row as a placeholder', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.ks2Placeholder).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress comparison for an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.suppressKs2Comparison).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNavItems', () => {
|
||||
it('omits sections with no data', () => {
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
expect(ids).not.toContain('ofsted');
|
||||
expect(ids).not.toContain('admissions');
|
||||
});
|
||||
|
||||
it('labels the results section by phase', () => {
|
||||
const primary = computeSchoolFlags(primaryFixture);
|
||||
const items = buildNavItems(primary, {
|
||||
ofsted: primaryFixture.ofsted, admissions: primaryFixture.admissions,
|
||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||
});
|
||||
expect(items.find((n) => n.id === 'results')?.label).toBe('SATs');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolSections`
|
||||
Expected: FAIL — `Cannot find module '@/lib/schoolSections'`.
|
||||
|
||||
- [ ] **Step 3: Implement the module**
|
||||
|
||||
Create `nextjs-app/lib/schoolSections.ts` by moving the logic verbatim from `components/SchoolDetailView.tsx` lines **211-267**. Lines 205-209 are the `deprivationDesc` helper — leave it where it is; Task 6 moves it into `LocalAreaSection`. Also bring `latestResults` (line 160). Preserve the comments — they explain the special-school and all-through reasoning and are load-bearing for future readers. The `navItems` push order must stay exactly as it is at lines 258-267; it is engagement-ordered from analytics.
|
||||
|
||||
- [ ] **Step 4: Run to verify they pass**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolSections`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Consume it from both views**
|
||||
|
||||
Replace the inline flag computations in `SchoolDetailView.tsx` and `SecondarySchoolDetailView.tsx` with a call to `computeSchoolFlags` / `buildNavItems`, destructuring the flags so the JSX below is untouched.
|
||||
|
||||
- [ ] **Step 6: Verify nothing regressed**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test`
|
||||
Expected: all PASS, characterization tests unmodified.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/lib/schoolSections.ts nextjs-app/__tests__/lib/schoolSections.test.ts \
|
||||
nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): extract derived flags and nav items to lib
|
||||
|
||||
Pure data-shape logic moves out of the client components so page.tsx can
|
||||
compute the section list on the server. Adds direct unit coverage for the
|
||||
all-through and special-school branches, which were previously only
|
||||
reachable through a full component render.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Merge the stylesheets
|
||||
|
||||
Isolated so that a visual regression bisects to the CSS merge rather than to a JSX move. Highest-risk task in the plan; no test catches a mistake here.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/schoolSections.module.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: a stylesheet exporting every class name used by section markup in either view, plus `genderBarSecondary` and `heroStatValueSecondary` variant classes.
|
||||
|
||||
- [ ] **Step 1: Copy the primary stylesheet as the base**
|
||||
|
||||
```bash
|
||||
mkdir -p nextjs-app/components/school
|
||||
cp nextjs-app/components/SchoolDetailView.module.css \
|
||||
nextjs-app/components/school/schoolSections.module.css
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the secondary-only classes**
|
||||
|
||||
33 class names exist only in `SecondarySchoolDetailView.module.css`. Append their rules unchanged. Confirm the list:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && python3 - <<'PY'
|
||||
import re
|
||||
c=lambda p: set(re.findall(r'\.([a-zA-Z][\w-]*)', open(p).read()))
|
||||
a=c('components/SchoolDetailView.module.css'); b=c('components/SecondarySchoolDetailView.module.css')
|
||||
print('\n'.join(sorted(b-a)))
|
||||
PY
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Union the 14 incidental-drift differences**
|
||||
|
||||
For these classes, add any property present in the secondary rule but missing from the primary rule. All are defensive overflow properties — `min-width: 0`, `overflow-wrap: break-word`, `word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`. Never remove a property the primary rule already has.
|
||||
|
||||
Affected: `.card`, `.sectionTitle`, `.metricCard`, `.metricValue`, `.metricsGrid`, `.tableWrapper`, `.heroStatCard`, `.heroStatLabel`, `.historyToggle`, `.ofstedReportLink`, `.genderSplitBoys`, `.genderSplitGirls`, `.genderSplitSep`, and the remaining drift class from the audit in Step 5.
|
||||
|
||||
- [ ] **Step 4: Add variant classes for the two real differences**
|
||||
|
||||
Keep the primary look on the base class and add the secondary look as a separate class:
|
||||
|
||||
```css
|
||||
/* Secondary detail pages render a slimmer gender bar on a translucent track.
|
||||
Kept as an explicit variant rather than reconciled, so the divergence is
|
||||
deliberate instead of accidental drift. */
|
||||
.genderBarSecondary {
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
/* Secondary hero stats sit tighter and omit the Georgia fallback. */
|
||||
.heroStatValueSecondary {
|
||||
gap: 0.4rem;
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Audit the merge**
|
||||
|
||||
Every shared class must now be a superset of both originals, except the two variants:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && python3 - <<'PY'
|
||||
import re
|
||||
def rules(p):
|
||||
out={}
|
||||
for m in re.finditer(r'\.([a-zA-Z][\w-]*)\s*\{([^{}]*)\}', open(p).read()):
|
||||
out.setdefault(m.group(1),[]).append({x.strip() for x in
|
||||
re.sub(r'\s+',' ',m.group(2)).strip().rstrip(';').split(';') if x.strip()})
|
||||
return out
|
||||
m=rules('components/school/schoolSections.module.css')
|
||||
for src in ['components/SchoolDetailView.module.css','components/SecondarySchoolDetailView.module.css']:
|
||||
for name, bodies in rules(src).items():
|
||||
if name in ('genderBar','heroStatValue'): continue
|
||||
if name not in m: print(f"MISSING .{name} (from {src})"); continue
|
||||
missing = bodies[0] - m[name][0]
|
||||
if missing: print(f"LOST .{name}: {missing} (from {src})")
|
||||
print("audit complete")
|
||||
PY
|
||||
```
|
||||
Expected: no `MISSING` or `LOST` lines before `audit complete`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/components/school/schoolSections.module.css
|
||||
git commit -m "$(cat <<'EOF'
|
||||
style(detail): merge the two detail stylesheets
|
||||
|
||||
Unions the 14 incidental-drift differences between the primary and secondary
|
||||
modules -- defensive overflow properties (min-width, overflow-wrap,
|
||||
max-width) that were fixed on one page and never back-ported. Keeps the two
|
||||
genuine visual differences, .genderBar and .heroStatValue, as explicit
|
||||
variant classes.
|
||||
|
||||
No intended visual change. Isolated so a regression bisects here rather than
|
||||
to a JSX move.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Extract the server sections
|
||||
|
||||
The bulk of the work, and a **pure move**. Every section below becomes a server component: no `'use client'`, no hooks, no event handlers.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/sectionShared.tsx`
|
||||
- Create: the 12 section files listed in File Structure
|
||||
- Create: `nextjs-app/components/school/AdmissionsViewToggle.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SchoolFlags` and `NavItem` from `@/lib/schoolSections`; `schoolSections.module.css`.
|
||||
- Produces: each section is a named export taking exactly the props its JSX reads. `AdmissionsViewToggle` is the only `'use client'` file created here, with props `{ title: ReactNode; subtitle: ReactNode; trendLabel: string; yearView: ReactNode; trendView: ReactNode }`.
|
||||
|
||||
- [ ] **Step 1: Extract the shared primitives**
|
||||
|
||||
Create `sectionShared.tsx` mirroring `components/compare/sectionShared.tsx`. Export server components `Section` (renders `<section id className={styles.card}>`), `SectionTitle`, `MetricGrid`, `MetricCard`. Re-export the stylesheet as `sectionStyles`, matching the compare module's convention.
|
||||
|
||||
- [ ] **Step 2: Move the shared sections**
|
||||
|
||||
These are the only two sections where you are merging two implementations rather than moving one, so the reconciliation rule matters. **Start from the primary version and add only what the secondary version has that the primary lacks.** Diff them first so you are working from the actual delta rather than reading both in full:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && diff <(sed -n '598,687p' components/SchoolDetailView.tsx | sed 's/^[ \t]*//') \
|
||||
<(sed -n '395,502p' components/SecondarySchoolDetailView.tsx | sed 's/^[ \t]*//')
|
||||
```
|
||||
|
||||
For every line the secondary has and the primary does not, decide one of two things and write the decision as a code comment: either it is **phase-specific** (gate it on a prop — e.g. `showSixthForm`), or it is **an improvement the primary never received** (keep it unconditionally). Never delete a branch the primary version has. If a difference is neither — a genuine behavioural conflict — stop and report it rather than guessing.
|
||||
|
||||
- `OfstedSection.tsx` — primary `SchoolDetailView.tsx:598-687`, secondary `SecondarySchoolDetailView.tsx:395-502` (80% similar). Props: `{ ofsted, urn, isReportCard, ofstedInspectedDate, oeifAllSameGrade, oeifAreas }`. Note `RC_CATEGORIES` (lines 44-53) includes `rc_sixth_form`, which only ever populates for secondary — it is already null-guarded, so it needs no prop.
|
||||
- `FinancesSection.tsx` — primary `SchoolDetailView.tsx:1314-1344`, secondary `SecondarySchoolDetailView.tsx:937-973` (91% similar). Same rule; run the equivalent diff.
|
||||
|
||||
- [ ] **Step 3: Move the primary-only sections**
|
||||
|
||||
Verbatim moves, changing only the `styles` import to `schoolSections.module.css` and replacing closed-over variables with props:
|
||||
|
||||
| Component | Source | Props |
|
||||
| --- | --- | --- |
|
||||
| `ResultsSection.tsx` | `SchoolDetailView.tsx:688-959` | `latestResults, flags, primaryAvg, secondaryAvg, schoolInfo` |
|
||||
| `AdmissionsSection.tsx` | `SchoolDetailView.tsx:960-1051` | `admissions, admissionsHistory, admissionsSummary, isAllThrough` |
|
||||
| `InclusionSection.tsx` | `SchoolDetailView.tsx:1052-1135` | `latestResults, census, hasGenderSplit` |
|
||||
| `HistorySection.tsx` | `SchoolDetailView.tsx:1136-1256` | `yearlyData, flags` |
|
||||
| `SchoolLifeSection.tsx` | `SchoolDetailView.tsx:1257-1289` | `absenceData` |
|
||||
| `LocalAreaSection.tsx` | `SchoolDetailView.tsx:1290-1313` | `deprivation` — bring `deprivationDesc` (lines 205-209) with it |
|
||||
|
||||
- [ ] **Step 4: Move the secondary-only sections**
|
||||
|
||||
| Component | Source | Props |
|
||||
| --- | --- | --- |
|
||||
| `GcseSection.tsx` | `SecondarySchoolDetailView.tsx:503-732` | `latestResults, flags, secondaryAvg, schoolInfo` |
|
||||
| `SecondaryAdmissionsSection.tsx` | `SecondarySchoolDetailView.tsx:733-792` | `admissions` |
|
||||
| `SecondaryHistorySection.tsx` | `SecondarySchoolDetailView.tsx:793-839` | `yearlyData, flags` |
|
||||
| `WellbeingSection.tsx` | `SecondarySchoolDetailView.tsx:840-936` | `absenceData, census` — pass `variant="secondary"` to select `genderBarSecondary` |
|
||||
|
||||
- [ ] **Step 5: Build the admissions toggle island**
|
||||
|
||||
The current code renders both views always and toggles the `hidden` attribute, so the island only owns `hidden` and `aria-pressed`. The buttons sit inside `.admissionsHeader` beside the `<h2>` while the viewport is a sibling below, so the component must span both to preserve the DOM exactly.
|
||||
|
||||
Create `nextjs-app/components/school/AdmissionsViewToggle.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import styles from './schoolSections.module.css';
|
||||
|
||||
/**
|
||||
* The only interactive part of the admissions section. Both views are always
|
||||
* in the DOM (matching the previous behaviour) and this toggles `hidden`, so
|
||||
* the server-rendered markup passed in as yearView/trendView never ships as
|
||||
* client JavaScript.
|
||||
*/
|
||||
export function AdmissionsViewToggle({
|
||||
title, subtitle, trendLabel, yearView, trendView,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle: ReactNode;
|
||||
trendLabel: string;
|
||||
yearView: ReactNode;
|
||||
trendView: ReactNode;
|
||||
}) {
|
||||
const [view, setView] = useState<'year' | 'trend'>('year');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.admissionsHeader}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
||||
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
|
||||
This year
|
||||
</button>
|
||||
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
|
||||
{trendLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle}
|
||||
<div className={styles.admissionsViewport}>
|
||||
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>{yearView}</div>
|
||||
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>{trendView}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
In `AdmissionsSection.tsx`, when `showAdmissionsTrend` is false, render the header and year view directly with **no** client component — those schools then ship zero admissions JavaScript.
|
||||
|
||||
- [ ] **Step 6: Verify it compiles**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck`
|
||||
Expected: PASS. The old views still exist and still render; nothing is wired up yet.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add nextjs-app/components/school/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): extract sections as server components
|
||||
|
||||
Moves the section markup out of the two client views into
|
||||
components/school/, mirroring the components/compare/ layout. Sections are
|
||||
server components; the only new client file is AdmissionsViewToggle, which
|
||||
owns the hidden/aria-pressed state and receives both views as server-rendered
|
||||
children.
|
||||
|
||||
Ofsted and Finances are shared between phases (80% and 91% similar);
|
||||
Admissions and History are not (14% and 40%) and stay separate.
|
||||
|
||||
Pure move -- no logic or markup changes. Not yet wired up.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add the shell and wire it up
|
||||
|
||||
Where the bytes actually move.
|
||||
|
||||
**Files:**
|
||||
- Create: `nextjs-app/components/school/SchoolDetailShell.tsx` + `.module.css`
|
||||
- Create: `nextjs-app/components/school/PrimarySchoolSections.tsx`, `SecondarySchoolSections.tsx`
|
||||
- Modify: `nextjs-app/app/school/[slug]/page.tsx`
|
||||
- Modify: `nextjs-app/__tests__/support/renderSchoolDetail.tsx`
|
||||
- Delete: `SchoolDetailView.tsx` + `.module.css`, `SecondarySchoolDetailView.tsx` + `.module.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 4-6.
|
||||
- Produces: `SchoolDetailShell` — `'use client'`, props `{ schoolInfo: School; navItems: NavItem[]; flags: SchoolFlags; children: ReactNode }`.
|
||||
|
||||
- [ ] **Step 1: Build the shell**
|
||||
|
||||
Create `SchoolDetailShell.tsx` from `SchoolDetailView.tsx` lines 1-595 — the back link, header, details reveal, hero map, compare CTA, sticky nav, jump sheet — plus the hooks at lines 79-104 and the effects at 120-203 and 270-300. Keep `'use client'`. It renders `{children}` where the sections used to begin (after the nav closes, at what is currently line 595).
|
||||
|
||||
`navItems` becomes a prop instead of being computed inline. The scroll-spy at line 283 uses `document.getElementById(id)` and needs no change — it finds server-rendered sections exactly as before.
|
||||
|
||||
For the shell's stylesheet, do not eyeball which rules are shell-only — determine it mechanically. Every class the shell's JSX references goes into `SchoolDetailShell.module.css`; classes referenced by both the shell and a section stay duplicated in both files (CSS Modules hash them independently, so duplication is correct, not a smell). List the shell's classes with:
|
||||
|
||||
```bash
|
||||
cd nextjs-app && grep -o 'styles\.[a-zA-Z][A-Za-z0-9_]*' components/school/SchoolDetailShell.tsx \
|
||||
| sort -u | sed 's/styles\.//'
|
||||
```
|
||||
|
||||
Copy each of those rules — including every media-query and `:hover`/`:focus` block that mentions them — from `SchoolDetailView.module.css` before deleting it in Step 6.
|
||||
|
||||
- [ ] **Step 2: Build the two composers**
|
||||
|
||||
Server components — no `'use client'`. Each returns the section sequence in the current order, gated by the same flags:
|
||||
|
||||
```tsx
|
||||
export function PrimarySchoolSections({ data, flags, nationalAvg }: PrimarySectionsProps) {
|
||||
const { ofsted, admissions, /* … */ } = data;
|
||||
return (
|
||||
<>
|
||||
{ofsted && <OfstedSection ofsted={ofsted} urn={data.schoolInfo.urn} /* … */ />}
|
||||
{flags.hasAnyResults && flags.latestResults && <ResultsSection /* … */ />}
|
||||
{admissions && <AdmissionsSection /* … */ />}
|
||||
{flags.hasInclusionData && <InclusionSection /* … */ />}
|
||||
{data.yearlyData.length > 0 && <HistorySection /* … */ />}
|
||||
{flags.hasSchoolLife && <SchoolLifeSection /* … */ />}
|
||||
{flags.hasDeprivation && <LocalAreaSection /* … */ />}
|
||||
{flags.hasFinance && <FinancesSection /* … */ />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The conditions must match `buildNavItems` exactly, or the nav will link to sections that do not exist.
|
||||
|
||||
`SecondarySchoolSections` follows the same shape with `GcseSection`, `SecondaryAdmissionsSection`, `SecondaryHistorySection`, `WellbeingSection`, `FinancesSection`.
|
||||
|
||||
- [ ] **Step 3: Rewire the page**
|
||||
|
||||
In `app/school/[slug]/page.tsx`, replace the `isSecondary ? <SecondarySchoolDetailView/> : <SchoolDetailView/>` block. Compute flags and nav items on the server, then wrap:
|
||||
|
||||
```tsx
|
||||
const flags = computeSchoolFlags({ schoolInfo, yearlyData, absenceData, census, deprivation, finance });
|
||||
const navItems = buildNavItems(flags, { ofsted, admissions, yearlyDataLength: yearlyData.length });
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
|
||||
<SchoolDetailShell schoolInfo={school_info} navItems={navItems} flags={flags}>
|
||||
{isSecondary
|
||||
? <SecondarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />
|
||||
: <PrimarySchoolSections data={data} flags={flags} nationalAvg={nationalAvg} />}
|
||||
</SchoolDetailShell>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
Keep the existing `isSecondary` / `isAllThrough` derivation exactly as it is — all-through schools must still route to the primary composer, which renders both KS2 and KS4 content.
|
||||
|
||||
- [ ] **Step 4: Swap the render helper — the moment of truth**
|
||||
|
||||
Rewrite **only** `__tests__/support/renderSchoolDetail.tsx`:
|
||||
|
||||
```tsx
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
const navItems = buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted, admissions: fixture.admissions,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
return render(
|
||||
<SchoolDetailShell schoolInfo={fixture.schoolInfo} navItems={navItems} flags={flags}>
|
||||
<PrimarySchoolSections data={fixture} flags={flags} nationalAvg={nationalAveragesFixture} />
|
||||
</SchoolDetailShell>,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the characterization tests**
|
||||
|
||||
Run: `cd nextjs-app && npm test -- schoolDetail.characterization`
|
||||
Expected: **PASS, with `schoolDetail.characterization.test.tsx` byte-identical to Task 2.**
|
||||
|
||||
This is the proof the refactor preserved behaviour. If a test fails, the refactor is wrong — fix the components, never the test. Confirm the file is untouched:
|
||||
|
||||
```bash
|
||||
git diff --exit-code nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Delete the old views**
|
||||
|
||||
```bash
|
||||
git rm nextjs-app/components/SchoolDetailView.tsx nextjs-app/components/SchoolDetailView.module.css \
|
||||
nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/components/SecondarySchoolDetailView.module.css
|
||||
```
|
||||
|
||||
Confirm nothing still imports them:
|
||||
```bash
|
||||
cd nextjs-app && grep -rn "SchoolDetailView\|SecondarySchoolDetailView" app components lib __tests__ | grep -v node_modules
|
||||
```
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 7: Full verification**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test && npm run build`
|
||||
Expected: all three PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A nextjs-app/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(detail): render sections on the server behind a client shell
|
||||
|
||||
page.tsx now composes the sections and passes them through
|
||||
SchoolDetailShell as children, so ~750 lines of static markup stop shipping
|
||||
as client JavaScript. The shell keeps what is genuinely interactive: back
|
||||
link, header reveal, hero map, compare CTA, sticky nav and scroll-spy.
|
||||
|
||||
The scroll-spy already located sections via document.getElementById, so it
|
||||
works unchanged against server-rendered children.
|
||||
|
||||
Characterization tests from the earlier commit pass unmodified.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Delete the unused SWR hooks
|
||||
|
||||
**Files:**
|
||||
- Delete: `nextjs-app/hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts`, `useSchoolDetails.ts`
|
||||
- Modify: `nextjs-app/package.json`
|
||||
|
||||
- [ ] **Step 1: Confirm nothing imports them**
|
||||
|
||||
```bash
|
||||
cd nextjs-app && grep -rn "useSchools\|useFilters\|useMetrics\|useSchoolDetails" app components lib __tests__ | grep -v node_modules
|
||||
```
|
||||
Expected: no output. If anything appears, stop and report it — do not delete.
|
||||
|
||||
- [ ] **Step 2: Delete and drop the dependency**
|
||||
|
||||
```bash
|
||||
git rm nextjs-app/hooks/useSchools.ts nextjs-app/hooks/useFilters.ts \
|
||||
nextjs-app/hooks/useMetrics.ts nextjs-app/hooks/useSchoolDetails.ts
|
||||
cd nextjs-app && npm uninstall swr
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run: `cd nextjs-app && npm run typecheck && npm test && npm run build`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A nextjs-app/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore: drop unused SWR hooks and dependency
|
||||
|
||||
useSchools, useFilters, useMetrics and useSchoolDetails were imported by
|
||||
nothing and were the only consumers of swr. All data fetching goes through
|
||||
lib/api.ts on the server.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: E2E coverage and measurement
|
||||
|
||||
CLAUDE.md requires e2e updates in the same PR as user-facing changes; these journeys gate whether staging is fit for human testing.
|
||||
|
||||
**Files:**
|
||||
- Modify: `e2e/tests/journeys.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Read the existing journeys**
|
||||
|
||||
Run: `cat e2e/tests/journeys.spec.ts` and match its existing helpers, selectors and naming. Note the staging-data constraint: year filters must use the **latest** year, not the oldest — staging carries only partial history.
|
||||
|
||||
- [ ] **Step 2: Add detail-page assertions**
|
||||
|
||||
Add a journey that visits a school detail page and asserts: the Ofsted section renders; the results section renders a figure; the sticky section nav renders and a jump link scrolls to its section; and — the regression this whole refactor risks — the admissions year/trend toggle switches views. Assert on user-visible text and roles, never CSS-module class names.
|
||||
|
||||
- [ ] **Step 3: Verify the spec parses**
|
||||
|
||||
Run: `cd e2e && npx playwright test --list`
|
||||
Expected: the new tests appear. Do not run them locally — they target staging, which is deployed from `main`.
|
||||
|
||||
- [ ] **Step 4: Measure the result**
|
||||
|
||||
```bash
|
||||
cd nextjs-app && rm -rf .next && npx next build >/dev/null 2>&1 && python3 - <<'PY'
|
||||
import re,os,gzip,glob
|
||||
for html in sorted(glob.glob('.next/server/app/**/*.html', recursive=True)):
|
||||
src=set(re.findall(r'/_next/(static/[^"\']+\.js)', open(html).read()))
|
||||
tot=sum(len(gzip.compress(open(os.path.join('.next',s),'rb').read()))
|
||||
for s in src if os.path.exists(os.path.join('.next',s)))
|
||||
print(f"{tot/1024:8.1f} KB gz {len(src):3d} scripts {html}")
|
||||
PY
|
||||
```
|
||||
|
||||
Record the figures for the PR description. Compare against the `cab7b4f` baseline: 172 KB gz shared baseline, `/admissions` 177 KB gz. **Report the route-specific figure and the shared baseline separately** — this change does not target the baseline, and an unchanged baseline is not a failure.
|
||||
|
||||
- [ ] **Step 5: Commit and open the PR**
|
||||
|
||||
```bash
|
||||
git add e2e/tests/journeys.spec.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test(e2e): cover school detail sections and admissions toggle
|
||||
|
||||
The server/client split moves the detail sections out of the client bundle;
|
||||
these journeys gate that the rendered page still behaves the same on staging.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git push -u origin perf/server-client-split
|
||||
```
|
||||
|
||||
Open the PR against `main` per the Gitea flow in `docs/DEPLOY.md`. The description must include: the before/after byte table from Step 4, the note that the shared baseline is deliberately unchanged, the two CSS variants introduced in Task 5, and any bugs found-but-not-fixed during the pure-move tasks.
|
||||
|
||||
Do **not** trigger the production promotion workflow — that is the human's call.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before claiming the work complete, confirm each of these with actual command output:
|
||||
|
||||
- [ ] `cd nextjs-app && npm run typecheck` — passes
|
||||
- [ ] `cd nextjs-app && npm test` — passes
|
||||
- [ ] `cd nextjs-app && npm run build` — passes
|
||||
- [ ] `git diff cab7b4f -- nextjs-app/__tests__/components/schoolDetail.characterization.test.tsx` — **empty** after Task 2's commit
|
||||
- [ ] `grep -rn "SchoolDetailView" nextjs-app/app nextjs-app/components` — no output
|
||||
- [ ] `grep -rn "swr" nextjs-app/package.json` — no output
|
||||
- [ ] CSS audit script from Task 5 Step 5 — no `MISSING` or `LOST` lines
|
||||
- [ ] Byte measurement recorded in the PR description
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -19,7 +19,7 @@
|
||||
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
|
||||
|
||||
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
|
||||
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
|
||||
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("27,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
|
||||
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
|
||||
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
|
||||
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
|
||||
@@ -57,7 +57,7 @@
|
||||
- Severity guess: P2
|
||||
|
||||
- **F6. Mobile hero omits the value proposition shown on desktop**
|
||||
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "24,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
|
||||
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "27,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
|
||||
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
|
||||
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
|
||||
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# Detail pages: server/client split
|
||||
|
||||
**Date:** 2026-07-30
|
||||
**Status:** Approved, ready for implementation planning
|
||||
**Branch:** `perf/server-client-split`
|
||||
|
||||
## Problem
|
||||
|
||||
Every page of the site ships ~172 KB gzipped of JavaScript before any
|
||||
page-specific code loads. The 404 page costs that much; `/admissions`, a static
|
||||
content page with no data, costs 177 KB. A well-tuned Next.js app baseline is
|
||||
90-110 KB.
|
||||
|
||||
The cause is that nearly the whole site is a client component.
|
||||
`SchoolDetailView.tsx` is 65 KB of source marked `'use client'`;
|
||||
`SecondarySchoolDetailView.tsx` is 46 KB. Both are overwhelmingly presentational
|
||||
markup driven by props the server already fetched. Every stat tile, table row and
|
||||
Ofsted badge is serialised into the RSC payload *and* shipped again as JavaScript
|
||||
to hydrate.
|
||||
|
||||
Measured on a production build of `cab7b4f` (Next 16.1.6, Turbopack):
|
||||
|
||||
| Metric | Value |
|
||||
| --- | --- |
|
||||
| Baseline JS, every page | 172 KB gz across 8 chunks |
|
||||
| `/admissions` (no data) | 177 KB gz |
|
||||
| Chart.js chunk | 64 KB gz (correctly lazy) |
|
||||
| Leaflet chunk | 41 KB gz (correctly lazy) |
|
||||
| CSS | ~30 KB gz, route-split |
|
||||
|
||||
**Scope of this document.** The table above characterises the whole problem; this
|
||||
design addresses one part of it — the route-level client JS on school detail
|
||||
pages, the site's highest-traffic and most SEO-critical route. The shared
|
||||
baseline and the other routes are separate work, tracked elsewhere.
|
||||
|
||||
## What makes this tractable
|
||||
|
||||
Two properties of the existing code make an aggressive split low-risk.
|
||||
|
||||
**The section bodies are almost entirely static.** Across ~750 lines of sections
|
||||
in `SchoolDetailView` (lines 596-1350), the only interactivity is five charts
|
||||
(already `dynamic({ ssr: false })` islands), fourteen `<MetricTooltip>`
|
||||
instances (already a small client leaf), and two buttons driving the admissions
|
||||
year/trend toggle. Everything else renders props.
|
||||
|
||||
**The scroll-spy is already decoupled.** `SchoolDetailView.tsx:283` locates
|
||||
sections with `document.getElementById(id)`, and `navItems` (lines 258-267) is
|
||||
computed purely from data. The navigation does not care which component renders
|
||||
the sections, so server-rendered children work unchanged.
|
||||
|
||||
There is also a precedent to follow: `components/compare/` is already decomposed
|
||||
this way — one file per section plus a `sectionShared.tsx` of primitives.
|
||||
|
||||
## Architecture
|
||||
|
||||
A server component imported by a client component becomes a client component. So
|
||||
the sections cannot be children of a client `SchoolDetailView`. They are composed
|
||||
in `page.tsx` and passed *through* the client shell as `children` — the pattern
|
||||
already used at `app/page.tsx:98-99`.
|
||||
|
||||
The header and navigation sit above the sections, and the sections are plain
|
||||
siblings, so a single `children` slot suffices. Named slots were considered and
|
||||
rejected: more ceremony, no benefit when sections render in sequence.
|
||||
|
||||
```
|
||||
app/school/[slug]/page.tsx server; fetches details + nationalAverages
|
||||
│ computes isSecondary / isAllThrough / navItems
|
||||
└── <SchoolDetailShell schoolInfo navItems> 'use client' — the only large client file
|
||||
│ back link · header + details reveal · hero map · compare CTA
|
||||
│ sticky section nav · scroll spy · jump sheet
|
||||
└── children: <PrimarySchoolSections/> | <SecondarySchoolSections/> server
|
||||
└── components/school/ all server, mirroring components/compare/
|
||||
OfstedSection · ResultsSection · AdmissionsSection
|
||||
InclusionSection · HistorySection · SchoolLifeSection
|
||||
LocalAreaSection · FinancesSection · WellbeingSection
|
||||
sectionShared.tsx Section/Card/Row primitives
|
||||
```
|
||||
|
||||
Phase branching stays out of the route file: two thin server components
|
||||
(`PrimarySchoolSections`, `SecondarySchoolSections`) return the section sequence,
|
||||
and `page.tsx` picks one.
|
||||
|
||||
### Client leaves inside server sections
|
||||
|
||||
Importing a client component into a server component is the legal direction, so
|
||||
these stay client and are used from server sections unchanged:
|
||||
|
||||
- `MetricTooltip` / `InfoPopover` — exist, unchanged
|
||||
- `PerformanceChart`, `SatsChart`, `AdmissionsTrendChart` — exist, stay `dynamic({ ssr: false })`
|
||||
- `AdmissionsViewToggle` — **new**, ~25 lines; receives server-rendered year-view
|
||||
and trend-view as two props and swaps between them
|
||||
- `DeltaChip`, `SpecialSchoolNote` — already server-compatible (no `'use client'`)
|
||||
|
||||
### What stays in the client shell, and why
|
||||
|
||||
The shell is genuinely interactive throughout: `router.back()`, the details
|
||||
reveal, `IntersectionObserver` hero-CTA tracking, the nav overflow fade, the
|
||||
Escape-to-close sheet, and the comparison context. The goal is not to shrink the
|
||||
shell but to stop the other ~750 lines being attached to it.
|
||||
|
||||
### Section sharing
|
||||
|
||||
Sharing is decided by measured similarity, not assumption. Comparing the two
|
||||
views' section bodies line-by-line (`difflib.SequenceMatcher` on stripped lines):
|
||||
|
||||
| Section | Primary | Secondary | Similarity | Decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Finances | 29 lines | 35 | 91% | Shared |
|
||||
| Ofsted | 88 | 107 | 80% | Shared |
|
||||
| History | 121 | 47 | 40% | Separate |
|
||||
| Admissions | 89 | 55 | 14% | Separate |
|
||||
|
||||
Admissions and History were assumed shareable when this design was first drafted;
|
||||
measurement during planning showed they are not — primary's admissions carries
|
||||
the year/trend toggle and chart, secondary's is a much simpler panel.
|
||||
|
||||
**This costs nothing in bytes.** The client-JS win comes from a section being a
|
||||
*server* component, not from being shared between views. Sharing is a
|
||||
maintainability benefit, taken only where it is cheap. Forcing the two
|
||||
low-similarity sections together would inflate the diff and the regression risk
|
||||
for no performance gain.
|
||||
|
||||
| Shared | Primary only | Secondary only |
|
||||
| --- | --- | --- |
|
||||
| Ofsted, Finances, `sectionShared` primitives, and the shell | Results (KS2), Admissions, History, Inclusion, School Life, Local Area | GCSEs, Admissions, History, Wellbeing |
|
||||
|
||||
All-through schools render both the KS2 and KS4 section sets via the primary
|
||||
composer.
|
||||
|
||||
### CSS strategy
|
||||
|
||||
Shared section components need one stylesheet, but the two existing CSS modules
|
||||
disagree. Measured on `cab7b4f`: `SchoolDetailView.module.css` defines 142
|
||||
classes, `SecondarySchoolDetailView.module.css` 112, with 79 names in common — of
|
||||
which 22 have different rules, and 16 of those are used inside section markup by
|
||||
both views (`.card`, `.sectionTitle`, `.metricCard`, `.metricValue`,
|
||||
`.metricsGrid`, `.tableWrapper`, the `heroStat*` set, the `genderSplit*` set,
|
||||
`.historyToggle`, `.ofstedReportLink`).
|
||||
|
||||
The 16 conflicts split into two kinds:
|
||||
|
||||
**Incidental drift (14 classes).** One view carries defensive overflow properties
|
||||
the other lacks — `min-width: 0`, `overflow-wrap: break-word`,
|
||||
`word-break: break-word`, `max-width: 100%`, `-webkit-overflow-scrolling: touch`.
|
||||
These are independent bug-fixes that were never back-ported, not design
|
||||
decisions. **Resolution: take the union.** No visible change, and it fixes latent
|
||||
long-school-name overflow on whichever page currently lacks the property.
|
||||
|
||||
**Real visual differences (2 classes).**
|
||||
|
||||
| Class | Primary | Secondary |
|
||||
| --- | --- | --- |
|
||||
| `.genderBar` | `height: 6px`, `background: var(--border-color, #e5dfd5)`, `width: 100%` | `height: 4px`, `background: rgba(0, 0, 0, 0.08)`, `margin-top: 0.45rem` |
|
||||
| `.heroStatValue` | `gap: 0.5rem`, `justify-content: flex-start`, Playfair fallback includes Georgia | `gap: 0.4rem`, Playfair fallback omits Georgia |
|
||||
|
||||
**Resolution: keep both looks as distinct classes** and have the owning section
|
||||
take a `variant: 'primary' | 'secondary'` prop selecting between them. Zero
|
||||
intended visual change, one shared stylesheet, and the divergence becomes
|
||||
explicit rather than accidental.
|
||||
|
||||
The merged stylesheet is `components/school/schoolSections.module.css`. Shell-only
|
||||
rules stay behind in the per-view modules. Classes outside the 79 shared names
|
||||
move across unchanged.
|
||||
|
||||
### Prerequisite: national averages move server-side
|
||||
|
||||
`SchoolDetailView.tsx:173-179` fetches `/api/national-averages` in a `useEffect`,
|
||||
and `primaryAvg` / `secondaryAvg` feed the England-comparison deltas across most
|
||||
sections. Those sections cannot be server components until that data arrives as a
|
||||
prop. `SecondarySchoolDetailView.tsx:91-93` has the same fetch.
|
||||
|
||||
`page.tsx` will fetch it in parallel with the school details (the endpoint is
|
||||
backend-cached for an hour) and pass `nationalAvg` down. This is a prerequisite,
|
||||
not an optional extra. It independently removes a client round-trip per detail
|
||||
page and puts the deltas in the initial HTML instead of popping in after
|
||||
hydration.
|
||||
|
||||
### Layout
|
||||
|
||||
`Footer` is already a server component — no change needed. `Navigation` genuinely
|
||||
requires client (`usePathname` plus the comparison count) and stays as it is.
|
||||
This item is much smaller than the original review estimated.
|
||||
|
||||
### Unused dependency
|
||||
|
||||
`hooks/useSchools.ts`, `useFilters.ts`, `useMetrics.ts` and `useSchoolDetails.ts`
|
||||
are imported by nothing. They are the only consumers of `swr`. All four files and
|
||||
the `swr` dependency are deleted.
|
||||
|
||||
## Testing
|
||||
|
||||
`SchoolDetailView` will not exist as a single component afterwards, so
|
||||
characterization tests must not import it directly. A single render helper
|
||||
absorbs the change:
|
||||
|
||||
```
|
||||
__tests__/support/renderSchoolDetail.tsx the only file that changes at the split
|
||||
before: render(<SchoolDetailView {...props} />)
|
||||
after: render(<SchoolDetailShell {...}><PrimarySchoolSections {...} /></SchoolDetailShell>)
|
||||
```
|
||||
|
||||
Every assertion imports that helper and never changes. Assertions passing
|
||||
untouched across the refactor is the proof that behaviour is preserved.
|
||||
|
||||
**Coverage:** Ofsted panel (rating, date, report link); KS2 results rows and
|
||||
England deltas; KS4 Attainment 8, Progress 8 and EBacc; admissions Q&A and the
|
||||
year/trend toggle; inclusion and wellbeing figures; the history table; finances;
|
||||
and the conditional `navItems` set.
|
||||
|
||||
**Fixtures:** four shapes — primary, secondary, all-through, and special school.
|
||||
The special-school path drops the England comparison via `isSpecialSchool()` and
|
||||
is the site of a known past regression (PR #70), so it gets explicit coverage.
|
||||
|
||||
**Environment gaps to close in `jest.setup.js`:** jsdom provides neither
|
||||
`IntersectionObserver` (used twice in each view) nor `window.scrollTo`. Chart.js
|
||||
needs a canvas, so the chart components are mocked. `next/jest` already handles
|
||||
CSS modules, and `next/navigation` is already globally mocked. Section components
|
||||
are synchronous functions, so React Testing Library renders both the server and
|
||||
client pieces in jsdom without special handling.
|
||||
|
||||
**E2E:** `e2e/journeys.spec.ts` gains detail-page assertions. CLAUDE.md requires
|
||||
e2e updates in the same PR as user-facing changes.
|
||||
|
||||
## Commit sequence
|
||||
|
||||
One PR, but the diff is large, so it is structured to stay reviewable and
|
||||
bisectable:
|
||||
|
||||
1. `test:` jest.setup additions (IntersectionObserver, scrollTo), chart mocks, fixtures
|
||||
2. `test:` characterization tests against the current components — all green before anything moves
|
||||
3. `refactor:` server-side `nationalAverages` in `page.tsx`; drop both `useEffect` fetches
|
||||
4. `refactor:` extract the shared derived-flags/`navItems` module
|
||||
5. `style:` merge the two CSS modules into `components/school/schoolSections.module.css` per the CSS strategy above
|
||||
6. `refactor:` extract `components/school/sectionShared.tsx` and the server sections (pure moves, no logic edits)
|
||||
7. `refactor:` add `SchoolDetailShell` and `AdmissionsViewToggle`; rewire `page.tsx`; delete the two old views
|
||||
8. `chore:` delete the four unused SWR hooks; drop `swr` from `package.json`
|
||||
9. `test:` extend `e2e/journeys.spec.ts` with detail-page assertions
|
||||
|
||||
Commits 6 and 7 move the bytes; commits 1 and 2 are the safety net that makes
|
||||
them safe. Commit 5 is isolated so a visual regression bisects to the CSS merge
|
||||
rather than to a JSX move. Commit 8 lands late so it cannot confuse a bisect.
|
||||
|
||||
## Verification
|
||||
|
||||
Before the work is claimed complete:
|
||||
|
||||
- `npm run typecheck`, `npm test` and `npm run build` all green
|
||||
- characterization tests passing **unmodified** from commit 2
|
||||
- before/after gzipped JS per route recorded in the PR description, measured the
|
||||
same way as the figures in the Problem section
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `/school/[slug]` route-specific JS drops to roughly 40-60 KB gz, from a client
|
||||
tree currently built out of a 65 KB and a 46 KB source file
|
||||
- No visual or behavioural change on any of the four school shapes
|
||||
- One fewer client API round-trip per detail page
|
||||
|
||||
**This change does not reduce the 172 KB baseline.** That baseline is react-dom,
|
||||
the Next.js runtime, and the layout's client components (`Navigation`,
|
||||
`ComparisonProvider`, `ComparisonToast`) — none of which this design touches, as
|
||||
`Navigation` legitimately needs `usePathname` and the comparison count. The
|
||||
baseline is a separate piece of work; it is cited above only to establish why
|
||||
route-level client JS matters. Reporting must state the two numbers separately so
|
||||
the unchanged baseline is not read as a failure of this change.
|
||||
|
||||
Budget enforcement in CI was considered and deliberately deferred: measurement is
|
||||
recorded in the PR, but no new CI gate is added in this change.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No visual redesign or copy changes to any section
|
||||
- No change to `Navigation`, `Footer`, or any other route
|
||||
- No CI bundle-budget gate
|
||||
- Bugs found mid-move are recorded, not fixed inline
|
||||
|
||||
## Risks
|
||||
|
||||
**DOM drift.** Moving ~750 lines of JSX risks subtle markup changes that CSS
|
||||
modules depend on. Commits 4 and 5 are strict moves and the section CSS moves
|
||||
with them; any styling change beyond the CSS-merge rules above is out of scope.
|
||||
|
||||
**CSS merge.** Merging two large stylesheets is the highest-risk part of the
|
||||
work. The union rule is mechanical, but a missed conflict shows up as a visual
|
||||
regression that no unit test catches. The merge gets its own commit, and the
|
||||
16 known conflicts are enumerated above so they can be verified individually.
|
||||
|
||||
**All-through schools.** The trickiest case: `isAllThrough` gates KS2 content
|
||||
back on for a secondary-phase school (`app/school/[slug]/page.tsx:150-154`). It
|
||||
gets a dedicated fixture.
|
||||
|
||||
**Large diff.** Accepted deliberately in favour of designing the shared section
|
||||
library against both consumers at once. Mitigated by the commit sequence above.
|
||||
|
||||
**`swr` removal** is safe — nothing imports those hooks.
|
||||
@@ -0,0 +1,309 @@
|
||||
# SEO Programme — Design
|
||||
|
||||
Date: 2026-08-20
|
||||
Status: awaiting review
|
||||
|
||||
## Problem
|
||||
|
||||
schoolcompare ranks second for "school compare" — an exact match for the
|
||||
brand and the domain. It ranks poorly for "compare schools", "school
|
||||
comparison" and "schools near me". The first is a naming artefact and
|
||||
transfers to nothing; the rest are the queries that actually carry parent
|
||||
demand.
|
||||
|
||||
The cause is structural, not editorial. The site publishes five route
|
||||
families:
|
||||
|
||||
/ /compare /rankings /admissions /school/[slug]
|
||||
|
||||
Location intent has no landing page at all. Every competitor outranking us
|
||||
on those queries wins with programmatic location pages:
|
||||
|
||||
| Competitor | URL pattern |
|
||||
|---------------|--------------------------------------------|
|
||||
| School Guide | `/best-schools-in/manchester` |
|
||||
| Locrating | `/the-best-primary-schools-in-Manchester_…`|
|
||||
| FindMySchool | `/best-primary-schools/manchester` |
|
||||
| Snobe | `/best-primary-schools/manchester` |
|
||||
| School Atlas | `/guides/best-primary-schools-manchester` |
|
||||
|
||||
"Schools near me" is a local-intent query. Google resolves it against the
|
||||
user's coordinates and serves pages that are *about a place*. A national
|
||||
homepage cannot win it. No title or description change fixes this; only
|
||||
pages Google can localise will.
|
||||
|
||||
## Baseline (measured 2026-08-20, production API)
|
||||
|
||||
| Measure | Value |
|
||||
|--------------------------------------------|---------|
|
||||
| Unique schools | 27,229 |
|
||||
| URLs in sitemap.xml | 27,232 |
|
||||
| Schools with 2024/25 performance data | 21,266 |
|
||||
| Schools with **no** current performance data| ~5,963 (22%) |
|
||||
| Welsh establishments (all metrics null) | 1,569 |
|
||||
| Overseas / offshore establishments | 467 |
|
||||
| Static URLs in sitemap | 3 |
|
||||
| Routes setting a canonical | 1 of 5 |
|
||||
|
||||
Three findings from that table drive the plan.
|
||||
|
||||
**We submit ~6,000 thin pages to Google.** `build_sitemap()`
|
||||
(`backend/app.py:73`) enumerates every URN regardless of whether the school
|
||||
has any data. Welsh establishments return `school_type: "Welsh
|
||||
establishment"` with every performance metric, Ofsted grade and phase field
|
||||
null. Overseas and offshore establishments ("BFPO Overseas Establishments",
|
||||
"Gibraltar Overseas Establishments", "Jersey Offshore Establishments") are
|
||||
in the local-authority list too. At 22% of the submitted corpus this is a
|
||||
site-wide quality signal problem and a crawl-budget waste, not a rounding
|
||||
error.
|
||||
|
||||
W1 item 4 removes 2,036 of those — every non-England establishment — taking
|
||||
the corpus to 25,193. The 3,927 that remain are English schools with no
|
||||
current data: mostly newly opened, special, nursery or alternative provision.
|
||||
Those are a template problem, not a corpus problem, and item 5 handles them
|
||||
separately.
|
||||
|
||||
**The homepage is its own competitor.** `app/page.tsx` accepts eleven search
|
||||
params (`search`, `local_authority`, `school_type`, `phase`, `page`,
|
||||
`postcode`, `radius`, `sort`, `gender`, `admissions_policy`,
|
||||
`has_sixth_form`) and sets no canonical. Every filter combination is a
|
||||
crawlable near-duplicate of the single page we are asking to rank for
|
||||
"compare schools".
|
||||
|
||||
**School pages are near-orphans.** Reachable from the sitemap and from site
|
||||
search, but almost nothing links to them contextually, so they accrue no
|
||||
internal authority.
|
||||
|
||||
Also noted: the sitemap emits invented `priority` values and no `lastmod`.
|
||||
Google ignores `priority` and `changefreq` entirely; `lastmod` is the field
|
||||
it does read, and we omit it.
|
||||
|
||||
## Keyword clusters
|
||||
|
||||
Ranked by judgement of UK parent search behaviour and by the competitive
|
||||
SERP evidence above. Google Search Console is connected, so cluster
|
||||
priorities are to be re-derived from measured impressions before build
|
||||
starts (see Workstream 0).
|
||||
|
||||
**C1 — Head "compare" terms.** compare schools · school comparison · school
|
||||
comparison tool · compare school performance · compare primary schools ·
|
||||
compare secondary schools · compare two schools
|
||||
|
||||
**C2 — League tables and rankings.** primary school league tables ·
|
||||
secondary school league tables · school league tables 2026 · SATs results by
|
||||
school · GCSE results by school · KS2 league tables · Progress 8 rankings ·
|
||||
best primary schools in [town] · top 10 primary schools in [LA]
|
||||
|
||||
**C3 — Local / near me.** schools near me · primary schools near me ·
|
||||
secondary schools near me · best schools near me · good schools near me ·
|
||||
schools in [town] · primary schools in [LA] · schools near [postcode] ·
|
||||
[postcode] school catchment
|
||||
|
||||
**C4 — Individual school long tail.** [school] ofsted · [school] SATs
|
||||
results · [school] catchment area · [school] reviews · [school] URN
|
||||
|
||||
**C5 — Admissions.** primary school admissions 2027 · national offer day
|
||||
2027 · school application deadline · school admissions appeal ·
|
||||
oversubscription criteria · distance criteria school admissions · didn't get
|
||||
first choice school · school admissions [LA]
|
||||
|
||||
**C6 — Metric explainers.** what is a good SATs score · what is Progress 8 ·
|
||||
what is Attainment 8 · expected standard KS2 meaning · scaled score
|
||||
explained · Ofsted grades explained · Ofsted report cards · pupil premium
|
||||
explained
|
||||
|
||||
**C7 — Head to head.** [school A] vs [school B] · academy vs community
|
||||
school · grammar school vs comprehensive · faith school vs community school
|
||||
|
||||
## Workstreams
|
||||
|
||||
### W0 — Measure before touching anything
|
||||
|
||||
Export a Google Search Console baseline: impressions, clicks, average
|
||||
position and CTR by query and by page, for the trailing 16 months. Bucket
|
||||
queries into C1–C7. This sets the counterfactual — without it, no later
|
||||
claim about lift is defensible, because school-search traffic is strongly
|
||||
seasonal (results day in December, offer day in March/April).
|
||||
|
||||
Re-rank C1–C7 against measured impressions and adjust the sequence below if
|
||||
the data disagrees with the judgement calls.
|
||||
|
||||
### W1 — Crawl hygiene and index sanity
|
||||
|
||||
Cheap, and it unblocks everything after it. Adding 5,000 pages on top of a
|
||||
corpus that is 22% thin would compound the existing problem.
|
||||
|
||||
1. Canonical on every route. `/`, `/rankings`, `/compare` and `/admissions`
|
||||
currently set none.
|
||||
2. The homepage canonicalises to `/` regardless of search params.
|
||||
3. `/compare?urns=…` gets `noindex, follow` — it is an unbounded parameter
|
||||
space with no standalone value.
|
||||
4. **England only — DONE.** Wales, the Crown Dependencies, Gibraltar and the
|
||||
service/overseas schools are removed from the corpus at the mart boundary,
|
||||
not hidden at the view layer. `dim_school` and `dim_location` both exclude
|
||||
`TypeOfEstablishment` in {25, 26, 30, 37} — Offshore schools, Service
|
||||
children's education, Welsh establishment, British schools overseas —
|
||||
listed once as `vars.non_england_school_type_codes` in `dbt_project.yml`.
|
||||
That removes 2,036 establishments and 29 local authorities, and because
|
||||
`build_sitemap()` reads the same marts, it drops them from the sitemap in
|
||||
the same stroke. `assert_england_only_schools` fails the pipeline if a GIAS
|
||||
refresh reintroduces them or if the two models drift apart.
|
||||
5. Prune the remaining thin pages: exclude any school with no performance data
|
||||
**and** no Ofsted record. Distinct from item 4 — these are English schools
|
||||
with nothing yet to show, so the fix may be a better template rather than
|
||||
removal.
|
||||
6. Rebuild the sitemap as a sitemap **index**: one child per page family,
|
||||
real `lastmod` from the data-load timestamp, `priority` and `changefreq`
|
||||
dropped.
|
||||
|
||||
### W2 — The location layer
|
||||
|
||||
The dominant lever. `dim_location` already carries `town`, `county`,
|
||||
`local_authority_name`, `parliamentary_constituency`, `latitude`,
|
||||
`longitude` and `postcode`, so no new ingestion is required.
|
||||
|
||||
Routes:
|
||||
|
||||
/schools/[la] e.g. /schools/manchester
|
||||
/schools/[la]/primary
|
||||
/schools/[la]/secondary
|
||||
/best-primary-schools/[town]
|
||||
/best-secondary-schools/[town]
|
||||
/schools/near/[outcode] e.g. /schools/near/m20
|
||||
/schools/near-me geolocating hub
|
||||
|
||||
**Thin-page threshold: generate a town or outcode page only where at least
|
||||
five schools have current performance data.** Below that, 301 to the parent
|
||||
LA page. This is the single most important constraint in the workstream —
|
||||
it is what separates a location layer from index bloat.
|
||||
|
||||
Each page must earn its place with content a parent would actually use, not
|
||||
a template shell:
|
||||
|
||||
- H1 matching the query intent ("Best primary schools in Manchester")
|
||||
- Counts framed usefully: "137 primary schools, 9 rated Outstanding"
|
||||
- A ranked table of the top 20 on the headline metric
|
||||
- Local average against the England average
|
||||
- Ofsted grade distribution
|
||||
- Map
|
||||
- Links to neighbouring towns and to the parent LA
|
||||
- An FAQ block (feeds `FAQPage` in W4)
|
||||
- Links to every school page in scope — this is what de-orphans W1's corpus
|
||||
|
||||
Sizing estimate: ~150 usable LAs × 3 ≈ 450; towns clearing the threshold
|
||||
≈ 1,200 × 2 ≈ 2,400; outcodes ≈ 2,300. Roughly **5,000 new pages**,
|
||||
comfortably inside a sitemap index and well under the per-file 50,000 limit.
|
||||
|
||||
### W3 — Make rankings indexable
|
||||
|
||||
`/rankings` is driven entirely by query params, so Google indexes
|
||||
approximately one page where there should be hundreds.
|
||||
|
||||
/rankings/[phase]/[metric]
|
||||
/rankings/[phase]/[metric]/[la]
|
||||
|
||||
The interactive filter UI stays; its state moves into real paths. Param
|
||||
forms canonicalise to the clean path. This is the direct play for C2.
|
||||
|
||||
### W4 — Structured data and internal linking
|
||||
|
||||
- Replace the bare `EducationalOrganization` on school pages with `School`,
|
||||
and populate it properly.
|
||||
- `BreadcrumbList` site-wide.
|
||||
- `ItemList` on every rankings and location page.
|
||||
- `FAQPage` on admissions and on location pages.
|
||||
- New school-page modules: "Other schools in [town]", "Nearby schools",
|
||||
"Compare with similar schools". Each links out to W2 and W3 pages, which
|
||||
is what circulates authority instead of stranding it.
|
||||
|
||||
Explicitly **not** doing `Dataset` or `AggregateRating` — no review corpus
|
||||
exists, and fabricating one would be both useless and a policy violation.
|
||||
|
||||
### W5 — Admissions expansion
|
||||
|
||||
One static page currently carries an entire cluster.
|
||||
|
||||
/admissions/[la] per-authority deadlines and offer day
|
||||
/admissions/appeals
|
||||
/admissions/national-offer-day
|
||||
|
||||
`school admissions [LA]` is high-intent and highly seasonal; per-authority
|
||||
pages are the natural unit.
|
||||
|
||||
### W6 — Explainer content
|
||||
|
||||
/guides/progress-8
|
||||
/guides/attainment-8
|
||||
/guides/sats-scaled-scores
|
||||
/guides/ofsted-grades
|
||||
/guides/expected-standard
|
||||
|
||||
Each links into the corresponding W3 rankings page. Cheap to build, and it
|
||||
is what gives the metric vocabulary enough topical weight to support C1–C4.
|
||||
|
||||
### W7 — Head-to-head pages
|
||||
|
||||
/compare/[school-a]-vs-[school-b]
|
||||
|
||||
C7 is uncontested and native to the product. It is also the easiest way to
|
||||
destroy everything W1 fixes: 27,229 schools generate 370 million pairs.
|
||||
**Curated pairs only** — same town, both with current data, both with real
|
||||
search demand — capped in the low thousands. Gated behind evidence that W2
|
||||
is indexing cleanly.
|
||||
|
||||
### W8 — Metadata rewrite for C1
|
||||
|
||||
Current homepage title is `schoolcompare | Compare every school in England`,
|
||||
which spends the most valuable position on the brand. Rewrite the homepage,
|
||||
rankings and compare titles and descriptions around C1 phrasing. Small
|
||||
change, and the cheapest item in the programme.
|
||||
|
||||
## Sequencing
|
||||
|
||||
W0 → W1 → W2 → W3 → W4 → W5 → W6 → (W7 if W2 indexes cleanly)
|
||||
|
||||
W1 before W2 is not negotiable: adding pages to a corpus that is 22% thin
|
||||
compounds the problem rather than diluting it.
|
||||
|
||||
This spec is a programme, not a single implementation plan. Each workstream
|
||||
gets its own plan and its own PR; W2 will likely need several. Only W0 and W1
|
||||
are ready to plan against today — the rest should be re-read after W0's
|
||||
Search Console baseline lands, because that data may reorder them.
|
||||
|
||||
## Testing
|
||||
|
||||
Per CLAUDE.md, user-facing behaviour changes extend the `e2e/` journeys in
|
||||
the same PR. Each workstream adds:
|
||||
|
||||
- W1: canonical present and correct on every route; `/compare?urns=` carries
|
||||
`noindex`; sitemap excludes a known dataless URN.
|
||||
- W2: a known LA, town and outcode page renders with the expected school
|
||||
count; a below-threshold town redirects to its LA.
|
||||
- W3: a clean rankings path renders; the param form canonicalises to it.
|
||||
- W4: JSON-LD parses and validates against the declared types.
|
||||
|
||||
## Risks
|
||||
|
||||
**Index bloat.** The failure mode of every programmatic SEO programme. The
|
||||
five-school threshold, the W1 prune and the W7 gate are the three controls.
|
||||
|
||||
**Helpful-content exposure.** Google's stance on templated location pages
|
||||
has hardened. The mitigation is that each page carries genuinely local
|
||||
computed data — real counts, real distributions, real local-vs-national
|
||||
comparison — rather than a name substituted into boilerplate.
|
||||
|
||||
**Build cost.** School pages already use ISR with a 7-day revalidate and
|
||||
`PRERENDER_SCHOOLS` gating full prerender. 5,000 more routes need the same
|
||||
treatment; full static generation of 32,000 pages is likely impractical in
|
||||
CI.
|
||||
|
||||
**Seasonality.** Results day and offer day dominate the traffic curve.
|
||||
Judging the programme on a mid-summer window would misread it in either
|
||||
direction. W0's baseline must be year-on-year, not month-on-month.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Catchment areas are Locrating's moat and a strong C3 driver
|
||||
(`[postcode] school catchment`). `fact_admissions` carries admission
|
||||
distances. Is deriving approximate catchment a later workstream, or out
|
||||
of scope?
|
||||
@@ -0,0 +1,278 @@
|
||||
# W2: The Location Layer — Design
|
||||
|
||||
Date: 2026-08-21
|
||||
Status: awaiting review
|
||||
Supersedes: workstream W2 in `2026-08-20-seo-programme-design.md`
|
||||
|
||||
Scope note: this covers four page families in one spec. Splitting them — towns
|
||||
and authorities first, outcodes and localities after — was proposed and
|
||||
declined in favour of building the layer in one pass. The decomposition
|
||||
argument was that the curated locality seed needs human review and would hold
|
||||
up 783 pages of measured demand behind it; that risk is accepted here, and the
|
||||
implementation plan should sequence the seed early enough that review time
|
||||
does not become the critical path.
|
||||
|
||||
## Problem
|
||||
|
||||
Location intent is the largest unserved demand the site has. In the 16-month
|
||||
Search Console baseline it draws **874 impressions, one click, average
|
||||
position 49.5**. The site does not compete.
|
||||
|
||||
Unlike named-school queries — which the same baseline showed to be
|
||||
navigational and unwinnable, since a parent typing "audley junior school"
|
||||
wants that school's own website — location queries have no incumbent owner.
|
||||
Nobody owns "primary schools in Brentwood" the way a school owns its name.
|
||||
|
||||
The cause is structural: the site has no page about a place. Every competitor
|
||||
ranking above it does.
|
||||
|
||||
## What the demand actually looks like
|
||||
|
||||
Every location query in the baseline is **town or district level**. Not one is
|
||||
an administrative area:
|
||||
|
||||
| Query | Impressions | Position |
|
||||
|-------|-------------|----------|
|
||||
| colleges in solihull | 112 | 51.2 |
|
||||
| schools in ramsey | 64 | 42.5 |
|
||||
| schools in crosby | 57 | 47.7 |
|
||||
| primary schools in beccles | 44 | 40.9 |
|
||||
| private schools in battersea | 41 | 71.9 |
|
||||
| secondary schools in brentwood | 37 | 56.1 |
|
||||
| secondary schools in canary wharf | 30 | 35.9 |
|
||||
|
||||
Three patterns follow directly, and they drive the whole design.
|
||||
|
||||
**Towns, not authorities.** The superseded W2 put `/schools/[la]` first and
|
||||
towns second. The data inverts that. Brentwood appears four times in different
|
||||
phrasings; Beccles twice. Both are towns, not authorities.
|
||||
|
||||
**Phase is part of the query**, not a filter applied afterwards: "primary
|
||||
schools in beccles", "secondary schools in brentwood", "colleges in solihull".
|
||||
|
||||
**London is searched by district** — Battersea, Canary Wharf — and the GIAS
|
||||
`town` field cannot serve it at all.
|
||||
|
||||
## Measured sizing
|
||||
|
||||
Counted against the live corpus of 25,185 schools, not estimated.
|
||||
|
||||
| Family | Viable (≥5 schools) | Below threshold |
|
||||
|--------|--------------------|-----------------|
|
||||
| Towns | **783** | 907 → redirect to authority |
|
||||
| Outcodes | **1,760** | 305 |
|
||||
| Local authorities | 154 | — |
|
||||
| London localities | ~100–150 (curated) | — |
|
||||
|
||||
With phase variants — 783 town pages plus roughly 700 primary and 250
|
||||
secondary variants, 154 authorities across three variants, 1,760 outcodes and
|
||||
the curated localities — the total lands near **4,000 pages**. Phase variants
|
||||
need their own threshold: there are 17,426 primaries but only 4,456 secondaries nationally,
|
||||
so most towns will support a primary page and not a secondary one.
|
||||
|
||||
## Two design problems this spec exists to solve
|
||||
|
||||
### 1. Town and authority names collide, and neither contains the other
|
||||
|
||||
67 viable towns share a name with a local authority. The obvious fix — let the
|
||||
authority absorb the town, since it sounds like a superset — **does not work**:
|
||||
|
||||
| Place | Schools in the town | Schools in the authority |
|
||||
|-------|--------------------|-----------------------|
|
||||
| Bedford | 104 | 86 |
|
||||
| Birmingham | 520 | 518 |
|
||||
| Derby | 157 | 119 |
|
||||
| Doncaster | 152 | 145 |
|
||||
|
||||
The authority is the larger set in only 43 of the 67. Postal towns cross
|
||||
authority boundaries, so these are overlapping sets that happen to share a
|
||||
name. Publishing both into one namespace produces near-duplicate pages, which
|
||||
is the specific failure that sinks programmatic SEO.
|
||||
|
||||
**Resolution: two namespaces.**
|
||||
|
||||
```
|
||||
/schools/[place] towns and London localities
|
||||
/schools/[place]/primary
|
||||
/schools/[place]/secondary
|
||||
/schools/authority/[la] local authorities
|
||||
/schools/authority/[la]/primary
|
||||
/schools/authority/[la]/secondary
|
||||
/schools/near/[outcode]
|
||||
```
|
||||
|
||||
Outcodes carry no phase variants: nobody searches "primary schools in SW11",
|
||||
so the variants would be pages without demand.
|
||||
|
||||
Every collision disappears by construction. `/schools/[place]` keeps the clean
|
||||
URL for the pattern that carries the demand; authorities get a namespace whose
|
||||
purpose is genuinely different — admissions are authority-run, and the
|
||||
authority page is the one that can speak to catchment policy and LA averages.
|
||||
|
||||
A place page and an authority page of the same name must each say plainly
|
||||
which set of schools they cover, or they read as duplicates to a reader even
|
||||
when they differ in fact.
|
||||
|
||||
### 2. London has no locality field
|
||||
|
||||
`town` collapses **1,819 London schools into the single value "London"**. A
|
||||
page listing all of them is useless, and borough pages do not help because
|
||||
people search "Battersea", not "Wandsworth".
|
||||
|
||||
No single field solves it:
|
||||
|
||||
| Search term | `parliamentary_constituency` | postcodes.io `admin_ward` |
|
||||
|-------------|------------------------------|---------------------------|
|
||||
| Battersea | **Battersea** ✓ | Northcote / Wandsworth Town ✗ |
|
||||
| Canary Wharf | Poplar and Limehouse ✗ | **Canary Wharf** ✓ |
|
||||
| Vauxhall | Vauxhall and Camberwell Green ✗ | **Vauxhall** ✓ |
|
||||
|
||||
And neither covers Clapham, Shoreditch or Peckham, which are postal and
|
||||
colloquial rather than administrative.
|
||||
|
||||
**Resolution: a curated seed mapping locality to outcodes.**
|
||||
|
||||
```
|
||||
pipeline/transform/seeds/locality_outcodes.csv
|
||||
locality_slug,locality_name,outcodes,region
|
||||
battersea,Battersea,"SW11|SW8",London
|
||||
canary-wharf,Canary Wharf,"E14",London
|
||||
clapham,Clapham,"SW4|SW9",London
|
||||
```
|
||||
|
||||
This needs **no new ingestion** — the corpus already has postcodes. It puts
|
||||
the fuzzy, contested part of the problem in a reviewable file rather than in
|
||||
derived logic, which suits it: locality boundaries are a judgement, not a
|
||||
fact. The repo already uses dbt seeds for curated reference data
|
||||
(`la_code_names.csv`, `gias_code_names.csv`), so this follows an established
|
||||
pattern.
|
||||
|
||||
The seed generalises past London. Any colloquial place — Jesmond, Chorlton,
|
||||
Clifton — can be defined by its outcodes without a schema change.
|
||||
|
||||
**Constraint:** a locality slug may not collide with a viable town slug. The
|
||||
place registry enforces this and fails the build rather than silently
|
||||
shadowing a town.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The place registry
|
||||
|
||||
One module owns the question "what places do we publish, and what is in each".
|
||||
Everything else reads from it: the pages, the sitemap, the internal links.
|
||||
|
||||
```
|
||||
backend/places.py
|
||||
|
||||
Place = { kind: "town"|"locality"|"authority"|"outcode",
|
||||
slug, name, urn_list, parent_authority | None }
|
||||
|
||||
build_place_registry(df) -> dict[str, Place]
|
||||
place_schools(slug, phase=None) -> list[School]
|
||||
```
|
||||
|
||||
Built once at startup from the same DataFrame the sitemap uses, and rebuilt by
|
||||
the existing `/api/admin/regenerate-sitemap` path after a pipeline run.
|
||||
Registry construction is where the threshold, the collision rules and the
|
||||
seed's uniqueness constraint are enforced — in one place, testable without a
|
||||
browser or a database.
|
||||
|
||||
### API
|
||||
|
||||
```
|
||||
GET /api/places the registry: slug, kind, name, count
|
||||
GET /api/places/{slug}?phase= aggregate + ranked schools for one place
|
||||
```
|
||||
|
||||
`/api/places` is what the sitemap and the internal-link modules enumerate.
|
||||
|
||||
### Routes
|
||||
|
||||
Next App Router, ISR with the same 7-day revalidate the school pages use.
|
||||
`generateStaticParams` gated behind an env flag, matching
|
||||
`PRERENDER_SCHOOLS`, because 3,900 more routes cannot be statically built in
|
||||
CI on every deploy.
|
||||
|
||||
## What each page must contain
|
||||
|
||||
A place page that is a name substituted into a template is the thing Google's
|
||||
helpful-content stance exists to demote. Each page carries computed local
|
||||
facts that exist nowhere else on the site:
|
||||
|
||||
- **H1** matching the query: "Primary schools in Brentwood"
|
||||
- **Counts framed usefully**: "29 schools, 4 rated Outstanding"
|
||||
- **A ranked table** of the top 20 on the phase's headline metric —
|
||||
`rwm_expected_pct` for primary, `attainment_8_score` for secondary, and for
|
||||
an unphased place page the metric matching whichever phase it holds more of
|
||||
- **The local average against the England average** — the one number a parent
|
||||
cannot get from a list
|
||||
- **Ofsted grade distribution** for the place
|
||||
- **A map**
|
||||
- **Links to neighbouring places** and to the parent authority
|
||||
- **An FAQ block**, feeding `FAQPage` structured data
|
||||
- **A link to every school page in scope** — this is what finally de-orphans
|
||||
the 23,000 school pages the original spec identified as near-orphans
|
||||
|
||||
## Thin-page controls
|
||||
|
||||
Three, and they are the difference between a location layer and index bloat:
|
||||
|
||||
1. **Five schools with current data minimum.** Below it, 301 to the parent
|
||||
authority. This drops 907 towns and 305 outcodes.
|
||||
2. **Per-phase thresholds.** A town with 30 primaries and 2 secondaries
|
||||
publishes a primary page and no secondary page.
|
||||
3. **No page without a local average.** If a place has too few schools with
|
||||
results to compute one, it has nothing to say that a list does not, and it
|
||||
falls back to the authority.
|
||||
|
||||
## Sitemap
|
||||
|
||||
Two new children in the existing index: `/sitemaps/places-{n}.xml` and
|
||||
`/sitemaps/outcodes-{n}.xml`. Per-family children are why the index was built
|
||||
in W1 — Search Console reports coverage per submitted sitemap, so indexation
|
||||
of the location layer is measurable separately from the school pages.
|
||||
|
||||
## Testing
|
||||
|
||||
Per `CLAUDE.md`, user-facing behaviour extends `e2e/tests/journeys.spec.ts` in
|
||||
the same PR.
|
||||
|
||||
**Unit (registry, no DB):** threshold enforcement; a sub-threshold town
|
||||
resolves to its authority; a locality slug colliding with a town fails the
|
||||
build; Bedford's town and authority pages hold different URN sets; per-phase
|
||||
thresholds.
|
||||
|
||||
**Backend:** `/api/places` shape; `/api/places/{slug}` aggregate correctness
|
||||
against a fixture; unknown slug 404s.
|
||||
|
||||
**e2e:** a known town, authority, locality and outcode page each render with
|
||||
the expected count; a below-threshold town 301s; every place page declares a
|
||||
canonical and appears in the sitemap; `/schools/bedford` and
|
||||
`/schools/authority/bedford` both resolve and state which set they cover.
|
||||
|
||||
## Risks
|
||||
|
||||
**Index bloat** is the failure mode of every programmatic SEO programme. The
|
||||
three controls above are the answer, and the per-family sitemap is how we find
|
||||
out early if they were not enough.
|
||||
|
||||
**Helpful-content exposure.** Templated location pages are exactly what
|
||||
Google's stance targets. The mitigation is that every page carries real
|
||||
computed local data — counts, distributions, local-versus-national comparison
|
||||
— rather than a name dropped into boilerplate. If indexation of the places
|
||||
sitemap stalls below roughly half, that is the signal to stop and rethink
|
||||
rather than to add more pages.
|
||||
|
||||
**Build cost.** ~4,000 additional ISR routes on top of 23,000 school pages.
|
||||
The env-flag gate on `generateStaticParams` keeps CI viable.
|
||||
|
||||
**Curation drift.** The locality seed is hand-maintained and will go stale as
|
||||
places change. It is small and reviewable, and a dbt test asserts every seed
|
||||
outcode matches at least one school so a typo fails the pipeline rather than
|
||||
publishing an empty page.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Catchment-area estimation. It is a strong driver for this cluster and
|
||||
`fact_admissions` carries the distances, but it is a modelling problem with
|
||||
real accuracy risk and deserves its own design.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,591 @@
|
||||
<title>Last distance offered — detail page mockup</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg-primary:#faf7f2; --bg-secondary:#f3ede4; --bg-card:#fff;
|
||||
--text-primary:#1a1612; --text-secondary:#5c564d; --text-muted:#6d685f;
|
||||
--accent-coral:#e07256; --accent-coral-dark:#b04a2e;
|
||||
--accent-teal:#296f6f; --accent-teal-light:#3a9e9e;
|
||||
--accent-gold:#c9a227; --accent-gold-text:#7a6800;
|
||||
--coral-bg:rgba(224,114,86,.12); --teal-bg:rgba(45,125,125,.12); --gold-bg:rgba(201,162,39,.12);
|
||||
--border:#e5dfd5; --shadow:0 2px 8px rgba(26,22,18,.06); --shadow-md:0 4px 20px rgba(26,22,18,.1);
|
||||
--radius-sm:4px; --radius-md:8px; --radius-lg:16px;
|
||||
--serif:'Playfair Display',Georgia,'Iowan Old Style',serif;
|
||||
--sans:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
}
|
||||
/* Mockup is a fixed light artefact — it mirrors the live app, which is light-only. */
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:var(--sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.6;
|
||||
padding:44px 20px 110px;font-variant-numeric:tabular-nums}
|
||||
.wrap{max-width:820px;margin:0 auto;display:flex;flex-direction:column;gap:0}
|
||||
|
||||
.pagehead h1{font-family:var(--serif);font-weight:600;font-size:clamp(26px,4vw,34px);letter-spacing:-.015em;text-wrap:balance}
|
||||
.pagehead p{color:var(--text-secondary);margin-top:10px;font-size:15px;max-width:64ch}
|
||||
.pagehead p + p{margin-top:8px}
|
||||
|
||||
.step{margin:60px 0 6px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
|
||||
.step h2{font-family:var(--serif);font-size:20px;font-weight:600;letter-spacing:-.01em}
|
||||
.step .where{font-size:12px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--accent-teal);
|
||||
background:var(--teal-bg);padding:3px 9px;border-radius:999px}
|
||||
.stepdesc{color:var(--text-muted);font-size:14px;margin-bottom:18px;max-width:66ch}
|
||||
.stepdesc code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:var(--bg-secondary);padding:1px 5px;border-radius:var(--radius-sm)}
|
||||
|
||||
/* ---- card shell mirrors SchoolDetailView .card ---- */
|
||||
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);
|
||||
box-shadow:var(--shadow);padding:26px 26px 24px}
|
||||
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.sectionTitle{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:-.01em}
|
||||
.sectionSub{color:var(--text-secondary);font-size:14px;margin-bottom:18px}
|
||||
|
||||
.seg{display:inline-flex;background:var(--bg-secondary);border-radius:999px;padding:3px;gap:2px;flex:none}
|
||||
.seg button{appearance:none;border:none;background:none;cursor:pointer;font:inherit;font-size:13px;font-weight:600;
|
||||
color:var(--text-muted);padding:6px 13px;border-radius:999px;white-space:nowrap;transition:background .15s,color .15s}
|
||||
.seg button[aria-pressed="true"]{background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow)}
|
||||
.seg button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
|
||||
|
||||
/* ---- tiles ---- */
|
||||
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(146px,1fr));gap:10px;margin-top:4px}
|
||||
.tile{background:var(--bg-secondary);border-radius:var(--radius-md);padding:14px 15px 13px}
|
||||
.tile .num{font-family:var(--serif);font-size:27px;font-weight:600;line-height:1.15;letter-spacing:-.01em;display:block}
|
||||
.tile .num .unit{font-family:var(--sans);font-size:15px;font-weight:600;margin-left:2px}
|
||||
.tile .num .sub{display:block;font-family:var(--sans);font-size:12.5px;font-weight:400;color:var(--text-muted);margin-top:1px}
|
||||
.tile .lbl{display:block;font-size:12.5px;color:var(--text-secondary);margin-top:5px;line-height:1.35}
|
||||
.tile.accent{background:var(--coral-bg)}
|
||||
.tile.accent .num{color:var(--accent-coral-dark)}
|
||||
.tile.newtile{background:var(--coral-bg);box-shadow:inset 0 0 0 1.5px var(--accent-coral)}
|
||||
.tile.newtile .num{color:var(--accent-coral-dark)}
|
||||
.newflag{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
|
||||
color:var(--accent-coral-dark);background:rgba(224,114,86,.2);padding:1px 6px;border-radius:999px;margin-bottom:6px}
|
||||
|
||||
/* ---- verdict banner ---- */
|
||||
.verdict{display:flex;gap:13px;align-items:flex-start;padding:15px 17px;border-radius:var(--radius-md);margin:2px 0 20px}
|
||||
.verdict.hard{background:var(--coral-bg)}
|
||||
.verdict .vic{flex:none;width:22px;height:22px;border-radius:50%;display:grid;place-items:center;margin-top:1px;
|
||||
background:var(--accent-coral-dark);color:#fff;font-size:12px;font-weight:700}
|
||||
.verdict .vhead{font-weight:700;font-size:16.5px;letter-spacing:-.01em;color:var(--accent-coral-dark);text-wrap:balance}
|
||||
.verdict .vsub{color:var(--text-secondary);font-size:13.5px;margin-top:3px}
|
||||
|
||||
/* ---- chart ---- */
|
||||
.chartwrap{overflow-x:auto}
|
||||
.chart{display:block;width:100%;min-width:460px;height:auto}
|
||||
.axtxt{font-family:var(--sans);font-size:11px;fill:var(--text-muted)}
|
||||
.ptlbl{font-family:var(--sans);font-size:12px;font-weight:700;fill:var(--accent-coral-dark)}
|
||||
.keyrow{display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
|
||||
.keyrow span{display:inline-flex;align-items:center;gap:7px}
|
||||
.kdot{width:11px;height:11px;border-radius:50%;flex:none}
|
||||
.kdot.line{background:var(--accent-coral)}
|
||||
.kdot.open{background:#fff;box-shadow:inset 0 0 0 2px var(--accent-teal)}
|
||||
.kdot.gap{background:repeating-linear-gradient(90deg,var(--text-muted) 0 2px,transparent 2px 4px);border-radius:0;height:2px}
|
||||
|
||||
/* ---- table ---- */
|
||||
.tblwrap{overflow-x:auto;margin-top:4px}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px;min-width:420px}
|
||||
thead th{text-align:right;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
|
||||
color:var(--text-muted);padding:0 10px 9px;border-bottom:1px solid var(--border)}
|
||||
thead th:first-child{text-align:left}
|
||||
tbody td{text-align:right;padding:11px 10px;border-bottom:1px solid var(--border)}
|
||||
tbody td:first-child{text-align:left;font-weight:600}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
tbody tr.now{background:var(--bg-secondary)}
|
||||
td.miss{color:var(--text-muted);font-weight:400}
|
||||
.pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;white-space:nowrap}
|
||||
.pill.over{background:var(--coral-bg);color:var(--accent-coral-dark)}
|
||||
.pill.ok{background:var(--teal-bg);color:var(--accent-teal)}
|
||||
.pill.na{background:var(--bg-secondary);color:var(--text-muted)}
|
||||
|
||||
/* ---- map ---- */
|
||||
.mapfig{border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--border);background:#eef2ec}
|
||||
.mapsvg{display:block;width:100%;height:auto}
|
||||
.maplegend{display:flex;flex-wrap:wrap;gap:14px 20px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
|
||||
.maplegend span{display:inline-flex;align-items:center;gap:7px}
|
||||
.swatch{width:14px;height:14px;border-radius:50%;flex:none}
|
||||
.swatch.now{background:rgba(224,114,86,.18);box-shadow:inset 0 0 0 2px var(--accent-coral)}
|
||||
.swatch.past{background:transparent;box-shadow:inset 0 0 0 1.5px rgba(176,74,46,.4)}
|
||||
.swatch.you{background:var(--accent-teal);border-radius:2px;transform:rotate(45deg);width:11px;height:11px}
|
||||
|
||||
/* ---- postcode check ---- */
|
||||
.checkbox{margin-top:20px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.checkhead{font-weight:700;font-size:15px;margin-bottom:3px}
|
||||
.checksub{font-size:13.5px;color:var(--text-muted);margin-bottom:12px}
|
||||
.checkform{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.checkform input{font:inherit;font-size:15px;padding:10px 13px;border:1px solid var(--border);border-radius:var(--radius-md);
|
||||
background:var(--bg-card);color:var(--text-primary);min-width:150px;flex:1 1 150px;text-transform:uppercase}
|
||||
.checkform input:focus-visible{outline:2px solid var(--accent-teal);outline-offset:1px;border-color:var(--accent-teal)}
|
||||
.checkform button{font:inherit;font-weight:600;font-size:15px;padding:10px 20px;border:none;border-radius:var(--radius-md);
|
||||
background:var(--accent-coral-dark);color:#fff;cursor:pointer;transition:background .15s}
|
||||
.checkform button:hover{background:#9c3f26}
|
||||
.checkform button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
|
||||
.result{margin-top:14px;background:var(--teal-bg);border-radius:var(--radius-md);padding:15px 17px}
|
||||
.result .rhead{font-weight:700;font-size:16px;color:var(--accent-teal);letter-spacing:-.01em;text-wrap:balance}
|
||||
.result .rsub{font-size:13.5px;color:var(--text-secondary);margin-top:4px}
|
||||
.yearstrip{display:flex;gap:4px;margin-top:12px;flex-wrap:wrap}
|
||||
.yr{font-size:11px;font-weight:600;padding:3px 7px;border-radius:var(--radius-sm);white-space:nowrap}
|
||||
.yr.in{background:rgba(45,125,125,.22);color:var(--accent-teal)}
|
||||
.yr.out{background:var(--coral-bg);color:var(--accent-coral-dark)}
|
||||
.yr.none{background:var(--bg-secondary);color:var(--text-muted)}
|
||||
|
||||
/* ---- notes / disclosure ---- */
|
||||
.note{display:flex;gap:9px;margin-top:16px;font-size:13px;color:var(--text-muted);line-height:1.55}
|
||||
.note .i{flex:none;width:17px;height:17px;border-radius:50%;background:var(--bg-secondary);color:var(--text-muted);
|
||||
font-size:11px;font-weight:700;display:grid;place-items:center;margin-top:2px}
|
||||
.disclosure{margin-top:16px;border-top:1px solid var(--border);padding-top:4px}
|
||||
.disclosure>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;padding:10px 0;
|
||||
font-weight:600;font-size:14px;color:var(--accent-teal)}
|
||||
.disclosure>summary::-webkit-details-marker{display:none}
|
||||
.disclosure>summary:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px;border-radius:var(--radius-sm)}
|
||||
.disclosure .chev{transition:transform .2s ease}
|
||||
.disclosure[open]>summary .chev{transform:rotate(90deg)}
|
||||
.disclosure .body{padding:2px 0 10px;font-size:13.5px;color:var(--text-secondary);display:flex;flex-direction:column;gap:10px}
|
||||
.disclosure .body b{color:var(--text-primary)}
|
||||
|
||||
/* ---- viewport stack (keeps card height stable across views) ---- */
|
||||
.viewport{display:grid}
|
||||
.viewport>.view{grid-area:1/1}
|
||||
.viewport>.view[hidden]{display:block;visibility:hidden;pointer-events:none}
|
||||
|
||||
/* ---- edge cases ---- */
|
||||
.cases{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
|
||||
.case{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px 17px}
|
||||
.case h3{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted);margin-bottom:10px}
|
||||
.case .body{font-size:14px;color:var(--text-secondary);line-height:1.5}
|
||||
.case .body strong{color:var(--text-primary)}
|
||||
.emptybox{background:var(--bg-secondary);border-radius:var(--radius-md);padding:13px 15px;font-size:13.5px;color:var(--text-secondary)}
|
||||
|
||||
/* ---- phone ---- */
|
||||
.phonerow{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start}
|
||||
.phone{width:330px;max-width:100%;border:9px solid #1a1612;border-radius:34px;overflow:hidden;box-shadow:var(--shadow-md);background:var(--bg-primary)}
|
||||
.phonebody{padding:14px 13px 20px;display:flex;flex-direction:column;gap:12px}
|
||||
.phone .card{padding:17px 16px 16px;border-radius:var(--radius-md)}
|
||||
.phone .sectionTitle{font-size:18px}
|
||||
.phone .tiles{grid-template-columns:1fr 1fr;gap:8px}
|
||||
.phone .tile{padding:11px 12px}
|
||||
.phone .tile .num{font-size:22px}
|
||||
.phone .chart{min-width:0}
|
||||
.phone .chartwrap{overflow:visible}
|
||||
.phonenote{font-size:13px;color:var(--text-muted);flex:1 1 240px;min-width:220px}
|
||||
.phonenote h3{font-family:var(--serif);font-size:17px;color:var(--text-primary);margin-bottom:8px;font-weight:600}
|
||||
.phonenote ul{padding-left:18px;display:flex;flex-direction:column;gap:7px}
|
||||
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>Last distance offered — school detail page</h1>
|
||||
<p>Adds the final-offer cut-off distance to the existing <b>Admissions</b> card, plus a catchment
|
||||
view on the map. Data covers one to ten years depending on the school and local authority, so every
|
||||
screen here is built around partial coverage rather than assuming a full run.</p>
|
||||
<p>Sample school: <b>Fairlawn Primary School</b>, Lewisham — 8 years of distance data out of 10 years of admissions data.</p>
|
||||
</div>
|
||||
|
||||
<!-- ============ 1. ADMISSIONS CARD ============ -->
|
||||
<div class="step">
|
||||
<h2>1. A distance tile joins the admissions tiles</h2>
|
||||
<span class="where">SchoolDetailView · #admissions</span>
|
||||
</div>
|
||||
<p class="stepdesc">No new section and no new nav entry — the number a parent actually asks for
|
||||
("how close do we need to live?") sits with the rest of the intake story. The segmented control gains a
|
||||
third view, <code>Distance</code>.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="cardhead">
|
||||
<h2 class="sectionTitle">Admissions</h2>
|
||||
<div class="seg" role="group" aria-label="Admissions view">
|
||||
<button type="button" aria-pressed="true" data-view="year">This year</button>
|
||||
<button type="button" aria-pressed="false" data-view="trend">10-year trend</button>
|
||||
<button type="button" aria-pressed="false" data-view="dist">Distance</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="sectionSub">Reception entry, September 2025.</p>
|
||||
|
||||
<div class="viewport">
|
||||
<!-- view: this year -->
|
||||
<div class="view" id="v-year">
|
||||
<dl class="tiles">
|
||||
<div class="tile">
|
||||
<dd class="num">60</dd><dt class="lbl">Places offered</dt>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<dd class="num">142</dd><dt class="lbl">Wanted it first</dt>
|
||||
</div>
|
||||
<div class="tile accent">
|
||||
<dd class="num">54<span class="sub">of 142 · 38%</span></dd>
|
||||
<dt class="lbl">Got their first choice</dt>
|
||||
</div>
|
||||
<div class="tile newtile">
|
||||
<span class="newflag">New</span>
|
||||
<dd class="num">0.31<span class="unit">mi</span><span class="sub">≈ 500 m · 6 min walk</span></dd>
|
||||
<dt class="lbl">Last distance offered</dt>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="note">
|
||||
<span class="i" aria-hidden="true">i</span>
|
||||
<span>The furthest home offered a place once siblings, faith and EHCP priority were applied.
|
||||
It is not a fixed catchment — it moves every year with the number of applications.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- view: distance -->
|
||||
<div class="view" id="v-dist" hidden>
|
||||
<div class="verdict hard">
|
||||
<span class="vic" aria-hidden="true">↓</span>
|
||||
<div>
|
||||
<div class="vhead">The catchment has halved in nine years</div>
|
||||
<div class="vsub">0.62 mi in 2016 → 0.31 mi in 2025. Four of the last five years tightened.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chartwrap">
|
||||
<svg class="chart" viewBox="0 0 700 250" role="img"
|
||||
aria-label="Last distance offered by year: 0.62 miles in 2016, 0.55 in 2017, not published in 2018, 0.48 in 2019, 0.51 in 2020, 0.44 in 2021, no cut-off needed in 2022, 0.39 in 2023, 0.35 in 2024, 0.31 in 2025.">
|
||||
<!-- grid -->
|
||||
<g stroke="#e5dfd5" stroke-width="1">
|
||||
<line x1="46" y1="30" x2="686" y2="30"/>
|
||||
<line x1="46" y1="80" x2="686" y2="80"/>
|
||||
<line x1="46" y1="130" x2="686" y2="130"/>
|
||||
<line x1="46" y1="180" x2="686" y2="180"/>
|
||||
</g>
|
||||
<line x1="46" y1="206" x2="686" y2="206" stroke="#d8d0c3" stroke-width="1.5"/>
|
||||
<g class="axtxt" text-anchor="end">
|
||||
<text x="38" y="34">0.8</text><text x="38" y="84">0.6</text>
|
||||
<text x="38" y="134">0.4</text><text x="38" y="184">0.2</text>
|
||||
</g>
|
||||
<text class="axtxt" x="46" y="16" text-anchor="start">miles</text>
|
||||
|
||||
<!-- gap segments (dashed = no figure published) -->
|
||||
<g fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="4 4" opacity=".55">
|
||||
<path d="M114 92 L182 110"/>
|
||||
<path d="M318 122 L386 132.5"/>
|
||||
</g>
|
||||
<!-- solid series -->
|
||||
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
points="46,75 114,92"/>
|
||||
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
points="182,110 250,102 318,122"/>
|
||||
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||
points="386,132.5 454,142.5 522,152.5"/>
|
||||
|
||||
<!-- points -->
|
||||
<g fill="#e07256" stroke="#fff" stroke-width="2">
|
||||
<circle cx="46" cy="75" r="5"/><circle cx="114" cy="92" r="5"/>
|
||||
<circle cx="182" cy="110" r="5"/><circle cx="250" cy="102" r="5"/>
|
||||
<circle cx="318" cy="122" r="5"/><circle cx="386" cy="132.5" r="5"/>
|
||||
<circle cx="454" cy="142.5" r="5"/>
|
||||
</g>
|
||||
<!-- latest, emphasised -->
|
||||
<circle cx="522" cy="152.5" r="7.5" fill="#b04a2e" stroke="#fff" stroke-width="2.5"/>
|
||||
|
||||
<!-- "no cut-off needed" marker, 2022 -->
|
||||
<circle cx="352" cy="46" r="6" fill="#fff" stroke="#296f6f" stroke-width="2.5"/>
|
||||
<text class="axtxt" x="352" y="34" text-anchor="middle" fill="#296f6f" font-weight="600">all offered</text>
|
||||
<line x1="352" y1="54" x2="352" y2="196" stroke="#296f6f" stroke-width="1.5" stroke-dasharray="3 4" opacity=".45"/>
|
||||
|
||||
<!-- endpoint labels -->
|
||||
<text class="ptlbl" x="46" y="63" text-anchor="start">0.62</text>
|
||||
<text class="ptlbl" x="530" y="157" text-anchor="start">0.31 mi</text>
|
||||
|
||||
<!-- year axis -->
|
||||
<g class="axtxt" text-anchor="middle">
|
||||
<text x="46" y="226">2016</text><text x="114" y="226">2017</text>
|
||||
<text x="182" y="226">2019</text><text x="250" y="226">2020</text>
|
||||
<text x="318" y="226">2021</text><text x="386" y="226">2023</text>
|
||||
<text x="454" y="226">2024</text><text x="522" y="226">2025</text>
|
||||
</g>
|
||||
<text class="axtxt" x="148" y="243" text-anchor="middle" fill="#6d685f">2018 not published</text>
|
||||
<text class="axtxt" x="352" y="243" text-anchor="middle" fill="#296f6f">2022 undersubscribed</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="keyrow">
|
||||
<span><i class="kdot line" aria-hidden="true"></i>Distance of the last place offered</span>
|
||||
<span><i class="kdot open" aria-hidden="true"></i>No cut-off needed — every applicant offered</span>
|
||||
<span><i class="kdot gap" aria-hidden="true"></i>Not published by the local authority</span>
|
||||
</div>
|
||||
|
||||
<div class="tblwrap" style="margin-top:22px">
|
||||
<table>
|
||||
<caption class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Last distance offered by year</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Year</th><th scope="col">Last distance</th><th scope="col">Places</th><th scope="col">Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="now"><td>2025</td><td>0.31 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2024</td><td>0.35 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2023</td><td>0.39 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2022</td><td class="miss">No cut-off needed</td><td>60</td><td><span class="pill ok">All offered</span></td></tr>
|
||||
<tr><td>2021</td><td>0.44 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2020</td><td>0.51 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2019</td><td>0.48 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2018</td><td class="miss">—</td><td>60</td><td><span class="pill na">Not published</span></td></tr>
|
||||
<tr><td>2017</td><td>0.55 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
<tr><td>2016</td><td>0.62 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<details class="disclosure">
|
||||
<summary><span class="chev" aria-hidden="true">›</span>What this number does and doesn't tell you</summary>
|
||||
<div class="body">
|
||||
<span><b>It is a result, not a rule.</b> It records how far the last successful applicant lived
|
||||
in a given year. Move one large sibling cohort and the figure shifts.</span>
|
||||
<span><b>Distance is the final tiebreak.</b> Children in care, EHCP places, siblings and — at faith
|
||||
schools — the faith criteria are ranked first. A family inside the distance can still miss out.</span>
|
||||
<span><b>Lewisham measures straight-line distance</b> from home to the school's main gate. Other
|
||||
authorities use walking routes, which are always longer for the same home.</span>
|
||||
<span><b>Gaps are normal.</b> An authority may not publish a figure, or the school may not have
|
||||
needed a distance cut-off that year. Both are shown here rather than hidden.</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 2. MAP ============ -->
|
||||
<div class="step">
|
||||
<h2>2. The cut-off drawn on the map</h2>
|
||||
<span class="where">SchoolHeroMap · catchment layer</span>
|
||||
</div>
|
||||
<p class="stepdesc">"0.31 miles" is abstract until you see it over your own streets. The latest year is a
|
||||
filled ring; earlier years sit behind it as hairlines, so the tightening reads instantly as a set of
|
||||
shrinking circles. Straight-line rings only — they're an illustration of the number, not a boundary.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="cardhead" style="margin-bottom:14px">
|
||||
<h2 class="sectionTitle">Where the last place went</h2>
|
||||
<div class="seg" role="group" aria-label="Map years">
|
||||
<button type="button" aria-pressed="true">2025 only</button>
|
||||
<button type="button" aria-pressed="false">Last 5 years</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure class="mapfig">
|
||||
<svg class="mapsvg" viewBox="0 0 700 400" role="img"
|
||||
aria-label="Map showing concentric catchment rings around Fairlawn Primary School: 0.62 miles in 2016 shrinking to 0.31 miles in 2025, with a marker for a sample home 0.24 miles away, inside the current ring.">
|
||||
<rect width="700" height="400" fill="#eef2ec"/>
|
||||
<!-- park -->
|
||||
<path d="M470 20 h230 v150 h-160 q-70 -20 -70 -80 z" fill="#dfe9dc"/>
|
||||
<!-- water -->
|
||||
<path d="M0 330 q120 -40 250 -10 t260 -20 l190 -30 v130 H0 z" fill="#dbe6ee"/>
|
||||
<!-- street grid -->
|
||||
<g stroke="#fff" stroke-width="7" stroke-linecap="round" opacity=".95">
|
||||
<path d="M-10 90 H710"/><path d="M-10 200 H710"/><path d="M-10 300 H710"/>
|
||||
<path d="M120 -10 V410"/><path d="M300 -10 V410"/><path d="M470 -10 V410"/><path d="M620 -10 V410"/>
|
||||
</g>
|
||||
<g stroke="#fff" stroke-width="3.5" opacity=".8">
|
||||
<path d="M-10 145 H710"/><path d="M-10 250 H710"/><path d="M210 -10 V410"/><path d="M385 -10 V410"/><path d="M550 -10 V410"/>
|
||||
</g>
|
||||
<!-- building blocks -->
|
||||
<g fill="#e4e2dc" opacity=".85">
|
||||
<rect x="132" y="102" width="60" height="30" rx="2"/><rect x="222" y="102" width="64" height="30" rx="2"/>
|
||||
<rect x="132" y="212" width="60" height="26" rx="2"/><rect x="222" y="212" width="64" height="26" rx="2"/>
|
||||
<rect x="400" y="102" width="56" height="30" rx="2"/><rect x="400" y="212" width="56" height="26" rx="2"/>
|
||||
</g>
|
||||
|
||||
<!-- historic rings (hairline) -->
|
||||
<g fill="none" stroke="#b04a2e" opacity=".38" stroke-width="1.5">
|
||||
<circle cx="300" cy="200" r="150"/>
|
||||
<circle cx="300" cy="200" r="126"/>
|
||||
<circle cx="300" cy="200" r="106"/>
|
||||
<circle cx="300" cy="200" r="90"/>
|
||||
</g>
|
||||
<text class="axtxt" x="300" y="44" text-anchor="middle" fill="#b04a2e" font-weight="600">2016 · 0.62 mi</text>
|
||||
<!-- current ring -->
|
||||
<circle cx="300" cy="200" r="75" fill="rgba(224,114,86,.18)" stroke="#e07256" stroke-width="3"/>
|
||||
<text class="axtxt" x="300" y="118" text-anchor="middle" fill="#b04a2e" font-weight="700" font-size="12.5">2025 · 0.31 mi</text>
|
||||
|
||||
<!-- school pin -->
|
||||
<circle cx="300" cy="200" r="11" fill="#b04a2e" stroke="#fff" stroke-width="3"/>
|
||||
<text class="axtxt" x="300" y="232" text-anchor="middle" fill="#1a1612" font-weight="700" font-size="12">Fairlawn Primary</text>
|
||||
|
||||
<!-- your home -->
|
||||
<g transform="translate(246,158)">
|
||||
<rect x="-7" y="-7" width="14" height="14" rx="2" fill="#296f6f" stroke="#fff" stroke-width="2.5" transform="rotate(45)"/>
|
||||
</g>
|
||||
<text class="axtxt" x="246" y="140" text-anchor="middle" fill="#296f6f" font-weight="700" font-size="12">Your home · 0.24 mi</text>
|
||||
</svg>
|
||||
</figure>
|
||||
|
||||
<div class="maplegend">
|
||||
<span><i class="swatch now" aria-hidden="true"></i>2025 cut-off — 0.31 mi</span>
|
||||
<span><i class="swatch past" aria-hidden="true"></i>Earlier years, 2016–2024</span>
|
||||
<span><i class="swatch you" aria-hidden="true"></i>Your home</span>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<div class="checkhead">Would you have got in?</div>
|
||||
<p class="checksub">We measure straight-line distance from your postcode, the same way Lewisham does.</p>
|
||||
<form class="checkform" onsubmit="return false">
|
||||
<label class="sr-only" for="pc" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Your postcode</label>
|
||||
<input id="pc" type="text" value="SE23 3NA" autocomplete="postal-code" spellcheck="false">
|
||||
<button type="submit">Check</button>
|
||||
</form>
|
||||
|
||||
<div class="result">
|
||||
<div class="rhead">0.24 miles away — inside the cut-off in all 8 years on record</div>
|
||||
<div class="rsub">That's 0.07 miles of headroom on 2025, the tightest year so far. 2018 has no published
|
||||
figure, and in 2022 every applicant was offered a place.</div>
|
||||
<div class="yearstrip">
|
||||
<span class="yr in">2016 ✓</span>
|
||||
<span class="yr in">2017 ✓</span>
|
||||
<span class="yr none">2018 –</span>
|
||||
<span class="yr in">2019 ✓</span>
|
||||
<span class="yr in">2020 ✓</span>
|
||||
<span class="yr in">2021 ✓</span>
|
||||
<span class="yr in">2022 ✓</span>
|
||||
<span class="yr in">2023 ✓</span>
|
||||
<span class="yr in">2024 ✓</span>
|
||||
<span class="yr in">2025 ✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<span class="i" aria-hidden="true">!</span>
|
||||
<span>An indication only. Distance is applied after siblings, faith and EHCP priority, and next year's
|
||||
cut-off depends on next year's applicants. Always check the school's own admissions policy.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 3. COVERAGE STATES ============ -->
|
||||
<div class="step">
|
||||
<h2>3. Coverage states</h2>
|
||||
<span class="where">Partial data is the normal case</span>
|
||||
</div>
|
||||
<p class="stepdesc">Coverage runs from ten years to none. Each state says something true rather than
|
||||
falling back on a generic "no data" — the reason a figure is absent is itself useful to a parent.</p>
|
||||
|
||||
<div class="cases">
|
||||
<div class="case">
|
||||
<h3>4+ years</h3>
|
||||
<div class="body">Full treatment: verdict banner, chart, table, map rings. <strong>The verdict line only
|
||||
appears with 4+ points</strong> — below that a two-year swing isn't a trend.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>2–3 years</h3>
|
||||
<div class="body">Tile and table, no verdict banner, single map ring for the latest year.
|
||||
<strong>"Only 3 years available"</strong> sits under the table.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>1 year</h3>
|
||||
<div class="body">Tile plus one map ring. No <code>Distance</code> tab — the tile and its footnote are
|
||||
the whole story.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Never oversubscribed</h3>
|
||||
<div class="body" style="margin-bottom:10px">Not missing data — good news, and it should read that way.</div>
|
||||
<div class="emptybox"><b style="color:var(--accent-teal)">Everyone who applied was offered a place</b>
|
||||
in each of the last 6 years, so no distance cut-off was needed.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>No data at all</h3>
|
||||
<div class="body" style="margin-bottom:10px">Replaces the current placeholder text in
|
||||
<code>SecondarySchoolDetailView.tsx:780</code>.</div>
|
||||
<div class="emptybox">Lewisham hasn't published cut-off distances for this school.
|
||||
<a href="#" style="color:var(--accent-teal);font-weight:600">The council's admissions page</a> may list them.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Not distance-ranked</h3>
|
||||
<div class="body" style="margin-bottom:10px">Grammar and some faith schools rank on test score or faith
|
||||
practice, so a distance figure would mislead.</div>
|
||||
<div class="emptybox">Places here are ranked by the entrance test, not by distance.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 4. MOBILE ============ -->
|
||||
<div class="step">
|
||||
<h2>4. Mobile</h2>
|
||||
<span class="where">≤ 640 px</span>
|
||||
</div>
|
||||
<p class="stepdesc">Tiles fall to two columns and the distance tile takes the full width beneath them, so the
|
||||
headline number survives the reflow. The chart drops its table on mobile behind a "See all years" disclosure.</p>
|
||||
|
||||
<div class="phonerow">
|
||||
<div class="phone">
|
||||
<div class="phonebody">
|
||||
<div class="card">
|
||||
<div class="cardhead" style="margin-bottom:6px">
|
||||
<h2 class="sectionTitle">Admissions</h2>
|
||||
</div>
|
||||
<p class="sectionSub" style="font-size:13px;margin-bottom:12px">Reception, Sept 2025</p>
|
||||
<div class="seg" style="margin-bottom:14px">
|
||||
<button type="button" aria-pressed="true">Year</button>
|
||||
<button type="button" aria-pressed="false">Trend</button>
|
||||
<button type="button" aria-pressed="false">Distance</button>
|
||||
</div>
|
||||
<dl class="tiles">
|
||||
<div class="tile"><dd class="num">60</dd><dt class="lbl">Places offered</dt></div>
|
||||
<div class="tile"><dd class="num">142</dd><dt class="lbl">Wanted it first</dt></div>
|
||||
</dl>
|
||||
<div class="tile newtile" style="margin-top:8px">
|
||||
<span class="newflag">New</span>
|
||||
<dd class="num" style="font-size:26px">0.31<span class="unit">mi</span>
|
||||
<span class="sub">≈ 500 m · 6 min walk</span></dd>
|
||||
<dt class="lbl">Last distance offered, 2025</dt>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="sectionTitle" style="margin-bottom:12px">Distance</h2>
|
||||
<div class="verdict hard" style="padding:12px 13px;margin:0 0 14px">
|
||||
<span class="vic" aria-hidden="true">↓</span>
|
||||
<div><div class="vhead" style="font-size:15px">Halved in nine years</div>
|
||||
<div class="vsub" style="font-size:12.5px">0.62 mi → 0.31 mi</div></div>
|
||||
</div>
|
||||
<div class="chartwrap">
|
||||
<svg class="chart" viewBox="0 0 300 150" role="img" aria-label="Last distance offered falling from 0.62 miles in 2016 to 0.31 miles in 2025.">
|
||||
<g stroke="#e5dfd5" stroke-width="1">
|
||||
<line x1="26" y1="20" x2="292" y2="20"/><line x1="26" y1="62" x2="292" y2="62"/><line x1="26" y1="104" x2="292" y2="104"/>
|
||||
</g>
|
||||
<g class="axtxt" text-anchor="end" font-size="9">
|
||||
<text x="21" y="23">0.8</text><text x="21" y="65">0.5</text><text x="21" y="107">0.2</text>
|
||||
</g>
|
||||
<path d="M26 55 L64 69" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M64 69 L102 84" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
|
||||
<path d="M102 84 L140 78 L178 95" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M178 95 L216 109" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
|
||||
<path d="M216 109 L254 116 L280 120" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<g fill="#e07256" stroke="#fff" stroke-width="1.8">
|
||||
<circle cx="26" cy="55" r="4"/><circle cx="64" cy="69" r="4"/><circle cx="102" cy="84" r="4"/>
|
||||
<circle cx="140" cy="78" r="4"/><circle cx="178" cy="95" r="4"/><circle cx="216" cy="109" r="4"/><circle cx="254" cy="116" r="4"/>
|
||||
</g>
|
||||
<circle cx="280" cy="120" r="6" fill="#b04a2e" stroke="#fff" stroke-width="2"/>
|
||||
<circle cx="197" cy="30" r="4.5" fill="#fff" stroke="#296f6f" stroke-width="2"/>
|
||||
<g class="axtxt" font-size="9" text-anchor="middle">
|
||||
<text x="26" y="132">'16</text><text x="140" y="132">'20</text><text x="280" y="132">'25</text>
|
||||
</g>
|
||||
<text class="ptlbl" x="280" y="140" text-anchor="middle" font-size="11">0.31 mi</text>
|
||||
</svg>
|
||||
</div>
|
||||
<details class="disclosure" style="margin-top:10px">
|
||||
<summary style="font-size:13px"><span class="chev" aria-hidden="true">›</span>See all 10 years</summary>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phonenote">
|
||||
<h3>Mobile decisions</h3>
|
||||
<ul>
|
||||
<li>Distance tile spans both columns — the number a parent came for shouldn't be a half-width cell.</li>
|
||||
<li>Chart keeps every year but labels only first, middle and last; the endpoint stays labelled.</li>
|
||||
<li>Year-by-year table collapses into a disclosure rather than forcing a horizontal scroll.</li>
|
||||
<li>Map rings reuse the existing full-screen hero map sheet, opened from the tile.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Segmented control on the admissions card swaps the stacked views.
|
||||
document.querySelectorAll('.seg button[data-view]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var group = btn.closest('.seg');
|
||||
group.querySelectorAll('button').forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
|
||||
var target = btn.dataset.view === 'dist' ? 'v-dist' : 'v-year';
|
||||
['v-year', 'v-dist'].forEach(function (id) {
|
||||
document.getElementById(id).hidden = id !== target;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -47,6 +47,12 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Fonts for the generated share card. File tracing already places these in
|
||||
# .next/standalone/assets, so the COPY above carries them — this makes the
|
||||
# dependency explicit rather than implicit in the tracer's behaviour, because
|
||||
# a miss here is a silent 500 on /opengraph-image, not a build failure.
|
||||
COPY --from=builder /app/assets ./assets
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { metadata as homeMetadata } from '@/app/page';
|
||||
import { metadata as rankingsMetadata } from '@/app/rankings/page';
|
||||
import { metadata as admissionsMetadata } from '@/app/admissions/page';
|
||||
import { generateMetadata as compareMetadata } from '@/app/compare/page';
|
||||
|
||||
describe('canonical URLs', () => {
|
||||
it('the homepage canonicalises to the bare root', () => {
|
||||
// page.tsx reads eleven search params. Without this, every filter
|
||||
// combination is a crawlable near-duplicate of the one page we want to
|
||||
// rank for "compare schools".
|
||||
expect(homeMetadata.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/');
|
||||
});
|
||||
|
||||
it('rankings canonicalises to the bare path', () => {
|
||||
expect(rankingsMetadata.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/rankings');
|
||||
});
|
||||
|
||||
it('admissions canonicalises to the bare path', () => {
|
||||
expect(admissionsMetadata.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/admissions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/compare indexability', () => {
|
||||
it('the bare compare page is indexable and canonical to itself', async () => {
|
||||
// This is the landing page for the "compare schools" head term.
|
||||
const meta = await compareMetadata({ searchParams: Promise.resolve({}) });
|
||||
expect(meta.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/compare');
|
||||
expect(meta.robots).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a comparison of specific schools is noindex, follow', async () => {
|
||||
// ~317 million pairs before triples. Indexing the parameter space would
|
||||
// swamp everything else in the corpus.
|
||||
const meta = await compareMetadata({
|
||||
searchParams: Promise.resolve({ urns: '100001,100002' }),
|
||||
});
|
||||
expect(meta.robots).toEqual({ index: false, follow: true });
|
||||
});
|
||||
|
||||
it('a parameterised comparison still canonicalises to the bare path', async () => {
|
||||
// follow:true plus a canonical means the outbound links to each school
|
||||
// page still pass value even though this URL is not indexed.
|
||||
const meta = await compareMetadata({
|
||||
searchParams: Promise.resolve({ urns: '100001,100002' }),
|
||||
});
|
||||
expect(meta.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/compare');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* W8 — snippet copy for the C1 cluster.
|
||||
*
|
||||
* The baseline (GSC, 16 months to 2026-08-20) showed these pages ranking on
|
||||
* page one and converting at a tenth of the normal rate: "compare school
|
||||
* performance" at position 6.1 with 0.43% CTR, against 9.16% for the brand
|
||||
* query from the same neighbourhood. The SERP is dominated by the DfE's own
|
||||
* "Compare school performance" service, so the job of this copy is to say
|
||||
* what that service does not offer, without losing intent match on the title.
|
||||
*
|
||||
* These tests guard the mechanics that make a snippet work — length, intent
|
||||
* keyword, differentiator, no brand-first — not the exact wording, which
|
||||
* should stay free to iterate.
|
||||
*/
|
||||
|
||||
// Google truncates titles near 60 characters and descriptions near 155.
|
||||
const TITLE_MAX = 60;
|
||||
const DESC_MIN = 110;
|
||||
const DESC_MAX = 155;
|
||||
|
||||
type Meta = { title?: unknown; description?: unknown };
|
||||
const titleOf = (m: Meta): string => {
|
||||
const t = m.title as string | { absolute?: string } | undefined;
|
||||
return typeof t === 'string' ? t : (t?.absolute ?? '');
|
||||
};
|
||||
|
||||
describe('C1 snippet copy', () => {
|
||||
const pages: Array<[string, Meta, RegExp]> = [
|
||||
['home', homeMetadata as Meta, /compare schools/i],
|
||||
['rankings', rankingsMetadata as Meta, /league table/i],
|
||||
['admissions', admissionsMetadata as Meta, /admission/i],
|
||||
];
|
||||
|
||||
for (const [name, meta, intent] of pages) {
|
||||
it(`${name}: title carries the search intent and fits the SERP`, () => {
|
||||
const t = titleOf(meta);
|
||||
expect(t).toMatch(intent);
|
||||
expect(t.length).toBeLessThanOrEqual(TITLE_MAX);
|
||||
});
|
||||
|
||||
it(`${name}: title does not open with the brand`, () => {
|
||||
// The measured 0.43% CTR came from a brand-first title. The most
|
||||
// valuable pixels go to the thing the searcher typed.
|
||||
expect(titleOf(meta).toLowerCase().startsWith('schoolcompare')).toBe(false);
|
||||
});
|
||||
|
||||
it(`${name}: description is long enough to be worth reading, short enough to survive`, () => {
|
||||
const d = meta.description as string;
|
||||
expect(d.length).toBeGreaterThanOrEqual(DESC_MIN);
|
||||
expect(d.length).toBeLessThanOrEqual(DESC_MAX);
|
||||
});
|
||||
}
|
||||
|
||||
it('the homepage description names what gov.uk does not publish', () => {
|
||||
// Admissions distance is the one fact the DfE service has no equivalent
|
||||
// for. If it ever leaves this description, the snippet is competing with
|
||||
// gov.uk on gov.uk's own ground.
|
||||
expect(homeMetadata.description).toMatch(/close you had to live|distance/i);
|
||||
});
|
||||
|
||||
it('/compare targets the tool phrasing rather than repeating the homepage', () => {
|
||||
// Two pages chasing one phrase is how a site competes with itself.
|
||||
return compareMetadata({ searchParams: Promise.resolve({}) }).then((m) => {
|
||||
expect(m.title).toMatch(/comparison tool/i);
|
||||
expect(m.title).not.toBe(titleOf(homeMetadata as Meta));
|
||||
});
|
||||
});
|
||||
|
||||
it('no C1 page claims a school count that will drift', () => {
|
||||
// The corpus moves with every data refresh; this repo has already shipped
|
||||
// one copy bug of that kind ("three schools" against MAX_SCHOOLS = 5).
|
||||
for (const [, meta] of pages) {
|
||||
expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page';
|
||||
|
||||
jest.mock('@/lib/places', () => ({
|
||||
...jest.requireActual('@/lib/places'),
|
||||
fetchPlace: jest.fn(async (kind: string, slug: string) =>
|
||||
slug === 'atlantis' ? null : ({
|
||||
place: { kind, slug, name: 'Brentwood', count: 29,
|
||||
parent_authority: 'Essex' },
|
||||
schools: [], averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
})),
|
||||
fetchPlaces: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe('place page metadata', () => {
|
||||
it('titles the page the way the place is searched', async () => {
|
||||
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||
expect((m.title as { absolute: string }).absolute).toMatch(/schools in brentwood/i);
|
||||
});
|
||||
|
||||
it('canonicalises to its own path on the www host', async () => {
|
||||
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
||||
expect(m.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/schools/brentwood');
|
||||
});
|
||||
|
||||
it('opts out of the layout template, which would double the brand', () => {
|
||||
// The root layout appends '| schoolcompare' to a plain string title, and
|
||||
// these titles already carry it — every place page shipped reading
|
||||
// '... | schoolcompare | schoolcompare' until this was made absolute.
|
||||
return placeMeta({ params: Promise.resolve({ place: 'brentwood' }) })
|
||||
.then((m) => {
|
||||
expect(typeof m.title).toBe('object');
|
||||
expect((m.title as { absolute: string }).absolute)
|
||||
.not.toMatch(/schoolcompare.*schoolcompare/);
|
||||
});
|
||||
});
|
||||
|
||||
it('an unknown place gets a not-found title rather than inventing one', async () => {
|
||||
const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) });
|
||||
expect(m.title).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The postcode check.
|
||||
*
|
||||
* This is the one place on the site that answers a question about a specific
|
||||
* family rather than about a school, so the tests here are mostly about what it
|
||||
* refuses to say — and that matters more now than it did, because there is only
|
||||
* one year to answer with. A run of years used to soften a single close call;
|
||||
* nothing does now, so the "too close to call" band is the whole safety margin.
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { CutoffMapPanel } from '@/components/school/CutoffMapPanel';
|
||||
import { CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered';
|
||||
import type { School, SchoolAdmissionDistance } from '@/lib/types';
|
||||
|
||||
// Leaflet needs a real layout box and network tiles; neither exists in jsdom.
|
||||
jest.mock('@/components/LeafletCutoffMapInner', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="cutoff-map" />,
|
||||
}));
|
||||
|
||||
const mockGeocode = jest.fn();
|
||||
jest.mock('@/lib/api', () => ({
|
||||
...jest.requireActual('@/lib/api'),
|
||||
geocodePostcode: (pc: string) => mockGeocode(pc),
|
||||
}));
|
||||
|
||||
const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School;
|
||||
|
||||
const cutoff = (distance_m: number | null, year = 2026): SchoolAdmissionDistance => ({
|
||||
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
|
||||
});
|
||||
|
||||
/** A point due north of the school, `metres` away. 1° latitude ≈ 111,320 m. */
|
||||
function northOf(metres: number) {
|
||||
return { latitude: SCHOOL.latitude! + metres / 111_320, longitude: SCHOOL.longitude! };
|
||||
}
|
||||
|
||||
const renderPanel = (c = cutoff(800)) =>
|
||||
render(<CutoffMapPanel schoolInfo={SCHOOL} cutoff={c} />);
|
||||
|
||||
async function check(postcode: string) {
|
||||
fireEvent.change(screen.getByLabelText('Your postcode'), { target: { value: postcode } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
|
||||
}
|
||||
|
||||
beforeEach(() => mockGeocode.mockReset());
|
||||
|
||||
describe('CutoffMapPanel', () => {
|
||||
it('renders nothing without a figure to compare against', () => {
|
||||
const { container } = renderPanel(cutoff(null));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing when the school has no coordinates', () => {
|
||||
const { container } = render(
|
||||
<CutoffMapPanel
|
||||
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
|
||||
cutoff={cutoff(800)}
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('rejects a malformed postcode without calling the geocoder', async () => {
|
||||
renderPanel();
|
||||
await check('not a postcode');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/);
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the year in the verdict, so the figure is never free-floating', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
renderPanel(cutoff(800, 2026));
|
||||
await check('SE23 3NA');
|
||||
|
||||
const result = await screen.findByRole('status');
|
||||
expect(result).toHaveTextContent(/inside the/);
|
||||
expect(result).toHaveTextContent(/September 2026/);
|
||||
});
|
||||
|
||||
it('reports a home clearly beyond the cut-off', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(5000));
|
||||
renderPanel(cutoff(800));
|
||||
await check('SE23 3NA');
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/beyond the/);
|
||||
});
|
||||
|
||||
it('declines to call a result that sits inside the measurement error', async () => {
|
||||
// Nominally inside the 800 m cut-off, but by half the uncertainty band —
|
||||
// which a postcode centroid cannot resolve. With only one year published
|
||||
// there is nothing else to fall back on, so this must not read as a pass.
|
||||
mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2));
|
||||
renderPanel(cutoff(800));
|
||||
await check('SE23 3NA');
|
||||
|
||||
const result = await screen.findByRole('status');
|
||||
expect(result).toHaveTextContent(/too close/);
|
||||
expect(result).toHaveTextContent(/measurement error/);
|
||||
// Explanation is supporting text, not part of the bold verdict line.
|
||||
expect(result.querySelector('[class*="cutoffCheckHeadline"]')!.textContent)
|
||||
.not.toMatch(/measurement error/);
|
||||
expect(result).not.toHaveTextContent(/^\S+ away — inside/);
|
||||
});
|
||||
|
||||
it('surfaces a postcode the geocoder cannot find', async () => {
|
||||
mockGeocode.mockResolvedValue(null);
|
||||
renderPanel();
|
||||
await check('ZZ99 9ZZ');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/);
|
||||
});
|
||||
|
||||
it('recovers from a geocoder failure instead of leaving a stale verdict', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
renderPanel();
|
||||
await check('SE23 3NA');
|
||||
await screen.findByRole('status');
|
||||
|
||||
mockGeocode.mockRejectedValue(new Error('network'));
|
||||
await check('SE23 3NB');
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
|
||||
});
|
||||
|
||||
it('keeps the map behind a request until there is a reason to show it', async () => {
|
||||
renderPanel();
|
||||
expect(screen.queryByTestId('cutoff-map')).not.toBeInTheDocument();
|
||||
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
await check('SE23 3NA');
|
||||
await screen.findByRole('status');
|
||||
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can also show the map without a postcode, on request', () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Show this distance on a map/ }));
|
||||
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('states its limits before it is used, not with the answer', () => {
|
||||
renderPanel();
|
||||
const caveat = screen.getByText(/Distance is the last criterion applied/);
|
||||
expect(caveat).toBeInTheDocument();
|
||||
expect(caveat).toHaveTextContent(/not a catchment boundary/);
|
||||
expect(caveat).toHaveTextContent(/walking route/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import type { PlaceDetail } from '@/lib/places';
|
||||
|
||||
const detail: PlaceDetail = {
|
||||
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 29,
|
||||
parent_authority: 'Essex', phases: ['primary'] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
|
||||
ofsted_grade: 1, phase: 'Primary' } as never,
|
||||
{ urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
|
||||
ofsted_grade: 3, phase: 'Primary' } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
};
|
||||
|
||||
describe('PlaceView', () => {
|
||||
it('leads with an H1 that matches how the place is searched', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByRole('heading', { level: 1 }))
|
||||
.toHaveTextContent(/primary schools in brentwood/i);
|
||||
});
|
||||
|
||||
it('states the count so the page says something before the table', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByText(/29 schools/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('compares the local average against England, which a list cannot', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('63');
|
||||
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('61');
|
||||
});
|
||||
|
||||
it('links every school in scope, which is what de-orphans them', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getAllByRole('link', { name: /Primary$/ })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('links to the parent authority so the place sits in a hierarchy', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByRole('link', { name: /Essex/i }))
|
||||
.toHaveAttribute('href', '/schools/authority/essex');
|
||||
});
|
||||
|
||||
it('shows the Ofsted distribution, not just a count of Outstanding', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.getByTestId('ofsted-distribution')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to neighbouring places so the page is not a dead end', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[{ kind: 'town', slug: 'romford', name: 'Romford', count: 40 }]} />);
|
||||
expect(screen.getByRole('link', { name: /Romford/ }))
|
||||
.toHaveAttribute('href', '/schools/romford');
|
||||
});
|
||||
|
||||
it('says nothing about an average it does not have', () => {
|
||||
render(<PlaceView detail={{ ...detail, averages:
|
||||
{ rwm_expected_pct: null, attainment_8_score: null } }}
|
||||
phase="primary" englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView structured data', () => {
|
||||
function jsonLd() {
|
||||
const { container } = render(<PlaceView detail={detail} phase="primary"
|
||||
englandAverage={61} neighbours={[]} />);
|
||||
const el = container.querySelector('script[type="application/ld+json"]');
|
||||
return JSON.parse(el!.textContent!);
|
||||
}
|
||||
|
||||
it('declares the page as a ranked list, not prose', () => {
|
||||
const types = jsonLd()['@graph'].map((n: { '@type': string }) => n['@type']);
|
||||
expect(types).toContain('ItemList');
|
||||
expect(types).toContain('BreadcrumbList');
|
||||
});
|
||||
|
||||
it('gives every listed school an absolute URL on the canonical host', () => {
|
||||
const list = jsonLd()['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList');
|
||||
expect(list.itemListElement).toHaveLength(2);
|
||||
for (const item of list.itemListElement) {
|
||||
expect(item.url).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\/school\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView phase variants', () => {
|
||||
it('links the phase variants that exist', () => {
|
||||
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByRole('link', { name: /Primary schools in Brentwood/i }))
|
||||
.toHaveAttribute('href', '/schools/brentwood/primary');
|
||||
});
|
||||
|
||||
it('links no variant for a phase below its own threshold', () => {
|
||||
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.queryByRole('link', { name: /Secondary schools in Brentwood/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not link sideways from a variant page to itself', () => {
|
||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.queryByRole('link', { name: /Primary schools in Brentwood/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView presentation', () => {
|
||||
// /schools/brentwood shipped with 8 of 27 rows blank: an unphased page shows
|
||||
// one primary-only measure for a list that also holds secondaries.
|
||||
const mixed: PlaceDetail = {
|
||||
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 4,
|
||||
parent_authority: 'Essex', phases: ['primary', 'secondary'] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
||||
{ urn: 2, school_name: 'Beta High', phase: 'Secondary',
|
||||
rwm_expected_pct: null, attainment_8_score: 47 } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
|
||||
};
|
||||
|
||||
it('gives each phase its own table rather than one column of blanks', () => {
|
||||
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByRole('heading', { name: /^Primary schools/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /^Secondary schools/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('82%')).toBeInTheDocument();
|
||||
expect(screen.getByText('47')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the measure in plain words, not jargon', () => {
|
||||
// The first cut said "RWM expected", which appears nowhere else on the site.
|
||||
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByText('Reading, writing & maths')).toBeInTheDocument();
|
||||
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/RWM expected/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says a missing result is unpublished rather than showing a bare dash', () => {
|
||||
const noResult: PlaceDetail = {
|
||||
...mixed,
|
||||
schools: [{ urn: 3, school_name: 'New Primary', phase: 'Primary',
|
||||
rwm_expected_pct: null, attainment_8_score: null } as never],
|
||||
};
|
||||
render(<PlaceView detail={noResult} englandAverage={61} neighbours={[]} />);
|
||||
expect(screen.getByText('Not published')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('styles school links to the site convention rather than browser default', () => {
|
||||
const { container } = render(<PlaceView detail={mixed} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
const link = container.querySelector('a[href^="/school/"]');
|
||||
expect(link?.className).toBeTruthy();
|
||||
});
|
||||
|
||||
it('a phased page shows one table and no phase headings', () => {
|
||||
render(<PlaceView detail={mixed} phase="primary" englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(screen.queryByRole('heading', { name: /^Secondary schools/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceView table alignment', () => {
|
||||
const aligned: PlaceDetail = {
|
||||
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 2,
|
||||
parent_authority: 'Essex', phases: ['primary'] },
|
||||
schools: [
|
||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
||||
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
||||
],
|
||||
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
||||
};
|
||||
|
||||
it('aligns the measure heading and its values with the same class', () => {
|
||||
// They were aligned by two different selectors whose specificity did not
|
||||
// match: `.table th:last-child` (0,2,1) won and went right, while `.num`
|
||||
// (0,1,0) lost to `.table td` (0,1,1) and stayed left. Sharing one class
|
||||
// is what makes them impossible to drift apart.
|
||||
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
const th = container.querySelectorAll('th')[1];
|
||||
const td = container.querySelectorAll('tbody td')[1];
|
||||
expect(th.className).toBeTruthy();
|
||||
expect(td.className).toBe(th.className);
|
||||
});
|
||||
|
||||
it('leaves the school-name column unclassed so it takes the spare width', () => {
|
||||
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
|
||||
neighbours={[]} />);
|
||||
expect(container.querySelectorAll('th')[0].className).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Last distance offered, rendered on both detail templates.
|
||||
*
|
||||
* The figure is the one number on these pages that a parent may act on — it is
|
||||
* easy to read as "we live inside the catchment, we will get a place". These
|
||||
* tests pin the things that stop it being read that way: the year is always
|
||||
* present, the caveat is always present, and the blanket "not available"
|
||||
* sentence appears only when it is actually true.
|
||||
*/
|
||||
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||
|
||||
const cutoff = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
||||
year: 2025,
|
||||
distance_m: 500,
|
||||
route_count: 1,
|
||||
la_name: 'Camden',
|
||||
distance_unit_raw: 'miles',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('primary detail page', () => {
|
||||
it('shows the figure with the year it belongs to', () => {
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49, year: 2024 }) });
|
||||
|
||||
expect(screen.getByText('0.48 miles')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2024');
|
||||
});
|
||||
|
||||
it('keeps the figure and its metric support readable as two numbers', () => {
|
||||
// They are flex children with a CSS gap and nothing between them in the
|
||||
// text layer, which read as "0.48 miles770 m" to a screen reader and to any
|
||||
// text matcher. Cheap to lose again, so pinned.
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49 }) });
|
||||
|
||||
const tile = document.querySelector('[class*="admissionsTileDistance"]')!;
|
||||
expect(tile.textContent).toMatch(/0\.48 miles\s+770 m/);
|
||||
expect(tile.textContent).not.toMatch(/miles\d/);
|
||||
});
|
||||
|
||||
it('never shows the figure without saying it is not a catchment', () => {
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() });
|
||||
|
||||
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/moves every year/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('flags that a banded school\'s figure is the widest of several routes', () => {
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ route_count: 4 }) });
|
||||
|
||||
expect(screen.getByText(/4 admission routes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing distance-related when the LA publishes none', () => {
|
||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: null });
|
||||
|
||||
expect(screen.queryByText(/Last distance offered/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/not a fixed catchment/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('carries the figure even with no EES admissions row', () => {
|
||||
// The two sources are independent; this school has a cut-off and no
|
||||
// admissions figures. Before this feature the section did not render at all.
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissions: null,
|
||||
admissionsHistory: [],
|
||||
admissionDistance: cutoff({ distance_m: 1421.05 }),
|
||||
});
|
||||
|
||||
expect(screen.getByText('0.88 miles')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondary detail page', () => {
|
||||
it('shows the figure with the year it belongs to', () => {
|
||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff({ distance_m: 3472.96 }) });
|
||||
|
||||
expect(screen.getByText('2.16 miles')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2025');
|
||||
});
|
||||
|
||||
it('drops the blanket "not available" line once a distance exists', () => {
|
||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff() });
|
||||
|
||||
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the authority that would hold the data when there is none', () => {
|
||||
// The old copy asserted "Historical distance cut-off data is not available
|
||||
// for this school" on every secondary page, including the ones whose
|
||||
// council does publish it.
|
||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: null });
|
||||
|
||||
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── The Distance section ───────────────────────────────────────────────
|
||||
|
||||
describe('Distance section', () => {
|
||||
it('appears for a school with a figure and coordinates', () => {
|
||||
const { container } = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).toBeInTheDocument();
|
||||
expect(screen.getByText('How far away are you?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays away when the school has no coordinates to measure from', () => {
|
||||
const { container } = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: null, longitude: null },
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays away when no figure has been published', () => {
|
||||
const { container } = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: null,
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no year-by-year record — that is held back as a paid feature', () => {
|
||||
// The page must not leak the history through a table, a chart or a strip of
|
||||
// per-year verdicts. The API no longer sends it either; this guards the
|
||||
// render side so a future component cannot quietly put it back.
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
|
||||
});
|
||||
|
||||
// Scoped to the section: the page has other tables (the history section's).
|
||||
const section = document.querySelector('#distance')!;
|
||||
expect(section.querySelector('table')).toBeNull();
|
||||
expect(screen.queryByText(/Last distance offered, by year/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Not published/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/too few to read as a trend/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondary Distance section', () => {
|
||||
it('renders on the secondary template too', () => {
|
||||
const { container } = renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
schoolInfo: { ...secondaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: cutoff({ distance_m: 3472.96, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains a selective school by how it admits rather than as missing data', () => {
|
||||
renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
schoolInfo: { ...secondaryFixture.schoolInfo, admissions_policy: 'Selective' },
|
||||
admissionDistance: null,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/ranked by the entrance test/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads a consistently undersubscribed school as good news', () => {
|
||||
renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
admissionDistance: null,
|
||||
admissionsHistory: [
|
||||
{ year: 2022, oversubscribed: false },
|
||||
{ year: 2023, oversubscribed: false },
|
||||
{ year: 2024, oversubscribed: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText(/has not needed a distance cut-off/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Characterization tests for the school detail pages.
|
||||
*
|
||||
* These describe behaviour that ALREADY EXISTS. They are written before the
|
||||
* server/client split and must pass UNMODIFIED after it — that is the whole
|
||||
* point. If one fails during the refactor, the refactor is wrong; fix the
|
||||
* components, never these assertions.
|
||||
*
|
||||
* All rendering goes through renderSchoolDetail / renderSecondarySchoolDetail,
|
||||
* the one file the refactor is permitted to change.
|
||||
*/
|
||||
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
primaryFixture, secondaryFixture, allThroughFixture, specialFixture,
|
||||
} from '../support/schoolFixtures';
|
||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
track: jest.fn(),
|
||||
getNavigationSource: () => 'direct',
|
||||
}));
|
||||
|
||||
// Chart.js needs a canvas jsdom does not provide, and Leaflet needs a real
|
||||
// window. Both are already lazy client islands; their content is out of scope.
|
||||
jest.mock('@/components/PerformanceChart', () => ({
|
||||
PerformanceChart: () => <div data-testid="performance-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SatsChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sats-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/AdmissionsTrendChart', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="admissions-trend-chart" />,
|
||||
}));
|
||||
jest.mock('@/components/SchoolHeroMap', () => ({
|
||||
SchoolHeroMap: () => <div data-testid="hero-map" />,
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
describe('Ofsted section', () => {
|
||||
it('renders the legacy OEIF grade, date and report link', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
|
||||
expect(screen.getByText('Ofsted Rating')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inspected 17 May 2023/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Good').length).toBeGreaterThan(0);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Ofsted reports/ });
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining(String(primaryFixture.schoolInfo.urn)),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the previous grade when it differs from the current one', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
expect(screen.getByText(/Previously:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Report Card layout with its category bands', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
|
||||
expect(screen.getByText('Ofsted Report Card')).toBeInTheDocument();
|
||||
expect(screen.getByText(/From November 2025, Ofsted replaced single overall grades/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Safeguarding')).toBeInTheDocument();
|
||||
expect(screen.getByText('Met')).toBeInTheDocument();
|
||||
expect(screen.getByText('Achievement')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KS2 results', () => {
|
||||
it('renders the combined RWM figure with its England comparison', async () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
|
||||
expect(screen.getByText('58%')).toBeInTheDocument();
|
||||
// 58 vs an England average of 61 → a -3 pts chip and the hint line.
|
||||
expect(await screen.findByText('England avg: 61%')).toBeInTheDocument();
|
||||
expect(await screen.findByText('-3 pts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KS4 results', () => {
|
||||
// These figures appear in both the hero stats and the history table, so
|
||||
// assert on presence rather than uniqueness.
|
||||
it('renders Attainment 8, Progress 8 and EBacc figures', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
|
||||
expect(screen.getAllByText('46.2').length).toBeGreaterThan(0);
|
||||
// formatProgress rounds to 1 decimal and signs positives: 0.31 → "+0.3"
|
||||
expect(screen.getAllByText(/\+0\.3/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/EBacc/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all-through schools', () => {
|
||||
// The highest-value assertion in this file. isAllThrough gates the KS2
|
||||
// content back on for a school that also has KS4 data, so an all-through
|
||||
// page must show BOTH key stages. This is the case most likely to break.
|
||||
it('renders both KS2 and KS4 figures on the same page', () => {
|
||||
renderSchoolDetail(allThroughFixture);
|
||||
|
||||
expect(screen.getAllByText('63%').length).toBeGreaterThan(0); // KS2 RWM
|
||||
expect(screen.getAllByText('48.7').length).toBeGreaterThan(0); // KS4 Attainment 8
|
||||
});
|
||||
|
||||
it('labels the KS2 block explicitly', () => {
|
||||
renderSchoolDetail(allThroughFixture);
|
||||
expect(screen.getAllByText(/Primary — KS2 SATs/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('special schools', () => {
|
||||
// Guards the PR #70 regression, where a special school displayed
|
||||
// "0% -62 below England" against a mainstream benchmark that does not fit.
|
||||
it('suppresses the England comparison', () => {
|
||||
renderSchoolDetail(specialFixture);
|
||||
expect(screen.queryByText(/England avg:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains why the comparison is missing', () => {
|
||||
renderSchoolDetail(specialFixture);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('This is a special school.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('admissions', () => {
|
||||
it('toggles between the year view and the multi-year trend', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderSchoolDetail(primaryFixture);
|
||||
|
||||
const yearBtn = screen.getByRole('button', { name: 'This year' });
|
||||
const trendBtn = screen.getByRole('button', { name: /3-year trend/ });
|
||||
expect(yearBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const yearView = container.querySelector('[class*="admissionsViewYear"]')!;
|
||||
const trendView = container.querySelector('[class*="admissionsViewTrend"]')!;
|
||||
expect(yearView).not.toHaveAttribute('hidden');
|
||||
expect(trendView).toHaveAttribute('hidden');
|
||||
|
||||
await user.click(trendBtn);
|
||||
|
||||
expect(trendBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(yearView).toHaveAttribute('hidden');
|
||||
expect(trendView).not.toHaveAttribute('hidden');
|
||||
});
|
||||
|
||||
it('renders the headline admissions figures', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
expect(screen.getByText('Places offered')).toBeInTheDocument();
|
||||
expect(screen.getByText('Got their first choice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the toggle when there is only one year of offer data', () => {
|
||||
renderSecondarySchoolDetail(secondaryFixture);
|
||||
expect(screen.queryByRole('button', { name: 'This year' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section navigation', () => {
|
||||
it('lists the sections that have data', () => {
|
||||
const { container } = renderSchoolDetail(primaryFixture);
|
||||
|
||||
for (const id of ['ofsted', 'results', 'admissions', 'history', 'finances']) {
|
||||
expect(container.querySelector(`#${id}`)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('omits sections with no data', () => {
|
||||
const { container } = renderSchoolDetail(specialFixture);
|
||||
|
||||
expect(container.querySelector('#ofsted')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('#admissions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Guards against "stranded" CSS: when markup moves between components, its
|
||||
* rules must move with it. CSS Modules hash class names per file, so a rule
|
||||
* left behind in another stylesheet silently stops applying — exactly how the
|
||||
* header's mobile `.headerDetails { display: none }` (and the section-nav
|
||||
* mobile swap) stopped working when the detail view was split into a client
|
||||
* shell plus server sections.
|
||||
*/
|
||||
|
||||
const SCHOOL_DIR = path.join(__dirname, '..', '..', 'components', 'school');
|
||||
|
||||
function classesDefinedIn(css: string): Set<string> {
|
||||
return new Set(Array.from(css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g), (m) => m[1]));
|
||||
}
|
||||
|
||||
function classesUsedIn(tsx: string): Set<string> {
|
||||
return new Set(Array.from(tsx.matchAll(/\bstyles\.([_a-zA-Z][\w]*)/g), (m) => m[1]));
|
||||
}
|
||||
|
||||
const components = fs
|
||||
.readdirSync(SCHOOL_DIR)
|
||||
.filter((f) => f.endsWith('.tsx'))
|
||||
.map((file) => {
|
||||
const source = fs.readFileSync(path.join(SCHOOL_DIR, file), 'utf8');
|
||||
const importMatch = source.match(/import\s+styles\s+from\s+['"](.+?\.module\.css)['"]/);
|
||||
return { file, source, stylesheet: importMatch?.[1] };
|
||||
})
|
||||
.filter((c) => c.stylesheet);
|
||||
|
||||
describe('school detail stylesheets', () => {
|
||||
it.each(components.map((c) => [c.file, c.source, c.stylesheet as string]))(
|
||||
'%s only uses classes its own stylesheet defines',
|
||||
(_file, source, stylesheet) => {
|
||||
const css = fs.readFileSync(path.join(SCHOOL_DIR, stylesheet), 'utf8');
|
||||
const defined = classesDefinedIn(css);
|
||||
const missing = Array.from(classesUsedIn(source)).filter((c) => !defined.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Last distance offered — formatting and the caveats attached to the figure.
|
||||
*
|
||||
* The assertions about the route note and the year are not cosmetic. A cut-off
|
||||
* shown without its year, or a banded school's widest cut-off shown as if it
|
||||
* were the only one, tells a parent something false about their chances of a
|
||||
* place — so both are pinned here rather than left to the component.
|
||||
*/
|
||||
|
||||
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
|
||||
import {
|
||||
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
|
||||
} from '@/components/school/lastDistanceOffered';
|
||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||
|
||||
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
||||
year: 2025,
|
||||
distance_m: 500,
|
||||
route_count: 1,
|
||||
la_name: 'Camden',
|
||||
distance_unit_raw: 'miles',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('formatCutoffDistance', () => {
|
||||
it('leads with miles, the unit councils publish in', () => {
|
||||
expect(formatCutoffDistance(500)).toEqual({ primary: '0.31 miles', secondary: '500 m' });
|
||||
expect(formatCutoffDistance(1609.344)).toEqual({ primary: '1.00 miles', secondary: '1.6 km' });
|
||||
});
|
||||
|
||||
it('switches to kilometres for the support figure above a kilometre', () => {
|
||||
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
|
||||
});
|
||||
|
||||
it('stays in miles at short range, where it used to swap to metres', () => {
|
||||
// The swap made a single number easier to read and a comparison harder:
|
||||
// "69 m away — inside the cut-off of 0.17 miles" asked the reader to
|
||||
// convert between units to check a claim we had already made for them.
|
||||
expect(formatCutoffDistance(27)).toEqual({ primary: '0.02 miles', secondary: '30 m' });
|
||||
expect(formatCutoffDistance(69)!.primary).toMatch(/miles$/);
|
||||
});
|
||||
|
||||
it('describes a distance too short for two decimal places', () => {
|
||||
// Rather than a flat "0.00 miles", which reads as no distance at all.
|
||||
expect(formatMiles(5)).toBe('under 0.01 miles');
|
||||
expect(formatMiles(0)).toBe('under 0.01 miles');
|
||||
expect(formatMiles(20)).toBe('0.01 miles');
|
||||
});
|
||||
|
||||
it('returns null rather than a zero cut-off', () => {
|
||||
// 0.0 miles appears in the source where a school filled on a higher
|
||||
// criterion. Rendered as "0.00 miles" it would read as the opposite.
|
||||
expect(formatCutoffDistance(0)).toBeNull();
|
||||
expect(formatCutoffDistance(null)).toBeNull();
|
||||
expect(formatCutoffDistance(undefined)).toBeNull();
|
||||
expect(formatCutoffDistance(Number.NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEntryYear', () => {
|
||||
it('names the intake, not the academic year', () => {
|
||||
// formatAcademicYear would render 2025 as "2025/26", which reads as a
|
||||
// school year rather than the September a child started.
|
||||
expect(formatEntryYear(2025)).toBe('September 2025');
|
||||
expect(formatEntryYear(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCutoff', () => {
|
||||
it('always carries the entry year alongside the figure', () => {
|
||||
const d = describeCutoff(distance({ distance_m: 772.49, year: 2024 }));
|
||||
expect(d).not.toBeNull();
|
||||
expect(d!.primary).toBe('0.48 miles');
|
||||
expect(d!.entryYear).toBe('September 2024');
|
||||
});
|
||||
|
||||
it('says nothing about routes for a school with one', () => {
|
||||
expect(describeCutoff(distance({ route_count: 1 }))!.routeNote).toBeNull();
|
||||
expect(describeCutoff(distance({ route_count: null }))!.routeNote).toBeNull();
|
||||
});
|
||||
|
||||
it('warns that a banded school\'s figure is the widest of several', () => {
|
||||
const note = describeCutoff(distance({ route_count: 4 }))!.routeNote;
|
||||
expect(note).toContain('4 admission routes');
|
||||
expect(note).toContain('shorter cut-off');
|
||||
});
|
||||
|
||||
it('is null when there is nothing publishable', () => {
|
||||
expect(describeCutoff(null)).toBeNull();
|
||||
expect(describeCutoff(undefined)).toBeNull();
|
||||
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── "Would we have got in?" ────────────────────────────────────────────
|
||||
|
||||
describe('compareToCutoff', () => {
|
||||
it('never states the two figures in different units', () => {
|
||||
/*
|
||||
* The reported defect: "69 m away — inside the September 2026 cut-off of
|
||||
* 0.17 miles". Both numbers are correct and the sentence is still useless,
|
||||
* because checking it means converting one of them.
|
||||
*
|
||||
* Swept across the range where the old formatter switched units, so a
|
||||
* future readability tweak to one figure cannot reintroduce the mismatch
|
||||
* in the other.
|
||||
*/
|
||||
const mixed: string[] = [];
|
||||
for (const homeM of [0, 5, 27, 69, 99, 100, 260, 800, 1609, 5000]) {
|
||||
for (const cutoffM of [30, 69, 100, 270, 1000, 3500]) {
|
||||
const { headline } = compareToCutoff(homeM, cutoffM, 2026);
|
||||
const hasMetres = /\d\s?m\b/.test(headline);
|
||||
const milesCount = (headline.match(/miles/g) ?? []).length;
|
||||
// Two figures, both in miles, and no metric reading anywhere near them.
|
||||
if (hasMetres || milesCount !== 2) {
|
||||
mixed.push(`home=${homeM}m cutoff=${cutoffM}m -> ${headline}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(mixed).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads back the reported case in one unit', () => {
|
||||
expect(compareToCutoff(69, 270, 2026).headline)
|
||||
.toBe('0.04 miles away — inside the September 2026 cut-off of 0.17 miles.');
|
||||
});
|
||||
|
||||
it('calls a clearly nearer home inside, and names the year', () => {
|
||||
const r = compareToCutoff(300, 800, 2026);
|
||||
expect(r.verdict).toBe('inside');
|
||||
expect(r.headline).toContain('September 2026');
|
||||
});
|
||||
|
||||
it('calls a clearly further home beyond', () => {
|
||||
expect(compareToCutoff(4000, 800, 2026).verdict).toBe('outside');
|
||||
});
|
||||
|
||||
it('refuses to call a result inside the measurement error, either way', () => {
|
||||
// A postcode centroid covers several addresses, so a margin this fine is
|
||||
// noise. With one published year there is no other year to fall back on,
|
||||
// which makes this band the only thing standing between a parent and a
|
||||
// place they do not have.
|
||||
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
||||
expect(compareToCutoff(800 + CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
||||
expect(compareToCutoff(800, 800, 2026).detail).toContain('measurement error');
|
||||
// The explanation is not welded to the headline, so it does not run at
|
||||
// headline weight in the result block.
|
||||
expect(compareToCutoff(800, 800, 2026).headline).not.toContain('measurement error');
|
||||
expect(compareToCutoff(300, 800, 2026).detail).toBeNull();
|
||||
});
|
||||
|
||||
it('treats the band as exclusive at its edge', () => {
|
||||
// Exactly on the boundary is still too close; one metre past it is not.
|
||||
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M, 800, 2026).verdict).toBe('too-close');
|
||||
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M - 1, 800, 2026).verdict).toBe('inside');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCutoffAbsence', () => {
|
||||
it('explains a selective school by how it admits, not as missing data', () => {
|
||||
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
|
||||
expect(s).toContain('entrance test');
|
||||
expect(s).not.toContain('has not published');
|
||||
});
|
||||
|
||||
it('reads a consistently undersubscribed school as good news', () => {
|
||||
const s = describeCutoffAbsence({
|
||||
localAuthority: 'Camden',
|
||||
admissionsHistory: [
|
||||
{ year: 2022, oversubscribed: false },
|
||||
{ year: 2023, oversubscribed: false },
|
||||
{ year: 2024, oversubscribed: false },
|
||||
],
|
||||
});
|
||||
expect(s).toContain('has not needed a distance cut-off');
|
||||
});
|
||||
|
||||
it('otherwise names the authority that would hold the figure', () => {
|
||||
expect(describeCutoffAbsence({ localAuthority: 'Camden' }))
|
||||
.toContain('Camden has not published');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import {
|
||||
primaryFixture, secondaryFixture, allThroughFixture, specialFixture,
|
||||
} from '../support/schoolFixtures';
|
||||
|
||||
describe('computeSchoolFlags', () => {
|
||||
it('treats an all-through school as both primary-capable and secondary', () => {
|
||||
const f = computeSchoolFlags(allThroughFixture);
|
||||
expect(f.isAllThrough).toBe(true);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.isPrimary).toBe(true);
|
||||
expect(f.isSecondary).toBe(false);
|
||||
expect(f.hasKS2Results).toBe(true);
|
||||
expect(f.hasKS4Results).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies an ordinary secondary', () => {
|
||||
const f = computeSchoolFlags(secondaryFixture);
|
||||
expect(f.isSecondary).toBe(true);
|
||||
expect(f.isAllThrough).toBe(false);
|
||||
expect(f.hasKS4Results).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the KS2 comparison for a special school', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressKs2Comparison).toBe(true);
|
||||
expect(f.suppressKs4Comparison).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an all-zero KS2 row as a placeholder', () => {
|
||||
const f = computeSchoolFlags(specialFixture);
|
||||
expect(f.ks2Placeholder).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress comparison for an ordinary primary', () => {
|
||||
const f = computeSchoolFlags(primaryFixture);
|
||||
expect(f.isSpecial).toBe(false);
|
||||
expect(f.suppressKs2Comparison).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a gender split only when census counts are present', () => {
|
||||
expect(computeSchoolFlags(primaryFixture).hasGenderSplit).toBe(true);
|
||||
expect(
|
||||
computeSchoolFlags({ ...primaryFixture, census: null }).hasGenderSplit,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNavItems', () => {
|
||||
it('omits sections with no data', () => {
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('ofsted');
|
||||
expect(ids).not.toContain('admissions');
|
||||
expect(ids).toContain('history');
|
||||
});
|
||||
|
||||
it('labels the results section by phase', () => {
|
||||
const label = (fixture: any) => {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
return buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
admissionDistance: null,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
}).find((n) => n.id === 'results')?.label;
|
||||
};
|
||||
|
||||
expect(label(primaryFixture)).toBe('SATs');
|
||||
expect(label(secondaryFixture)).toBe('GCSEs');
|
||||
expect(label(allThroughFixture)).toBe('Results');
|
||||
});
|
||||
|
||||
it('opens the admissions entry for a cut-off distance with no EES admissions', () => {
|
||||
// 3% of the schools that render have one source and not the other. The nav
|
||||
// condition and the section's render condition have to agree, or the sticky
|
||||
// nav links to an anchor that was never rendered.
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null,
|
||||
admissions: null,
|
||||
admissionDistance: { year: 2025, distance_m: 500, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles' },
|
||||
yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toContain('admissions');
|
||||
});
|
||||
|
||||
it('keeps the engagement-led ordering', () => {
|
||||
const flags = computeSchoolFlags(primaryFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: primaryFixture.ofsted,
|
||||
admissions: primaryFixture.admissions,
|
||||
admissionDistance: null,
|
||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toEqual([
|
||||
'ofsted', 'results', 'admissions', 'inclusion',
|
||||
'history', 'school-life', 'local-area', 'finances',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSecondaryFlags', () => {
|
||||
it('reads results and sixth form from the secondary fixture', () => {
|
||||
const f = computeSecondaryFlags(secondaryFixture);
|
||||
expect(f.hasResults).toBe(true);
|
||||
expect(f.hasSixthForm).toBe(false);
|
||||
expect(f.hasWellbeing).toBe(true);
|
||||
});
|
||||
|
||||
it('reports sixth form when GIAS flags it', () => {
|
||||
expect(computeSecondaryFlags(allThroughFixture).hasSixthForm).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the comparison for a special school', () => {
|
||||
const f = computeSecondaryFlags(specialFixture);
|
||||
expect(f.isSpecial).toBe(true);
|
||||
expect(f.suppressComparison).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag Progress 8 as suspended for pre-2024/25 cohorts', () => {
|
||||
expect(computeSecondaryFlags(secondaryFixture).p8Suspended).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSecondaryNavItems', () => {
|
||||
it('uses the secondary section ids', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: secondaryFixture.ofsted,
|
||||
admissions: secondaryFixture.admissions,
|
||||
admissionDistance: null,
|
||||
yearlyDataLength: secondaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
|
||||
});
|
||||
|
||||
it('opens the admissions entry for a cut-off distance alone', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: null,
|
||||
admissions: null,
|
||||
admissionDistance: { year: 2025, distance_m: 2400, route_count: 4, la_name: 'Islington', distance_unit_raw: 'miles' },
|
||||
yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).toContain('admissions');
|
||||
});
|
||||
|
||||
it('gates History on more than one year, unlike the primary page', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('history');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SITE_URL, absoluteUrl } from '@/lib/site';
|
||||
|
||||
describe('SITE_URL', () => {
|
||||
it('is the www host, which is the one that serves a 200', () => {
|
||||
// The apex 301s to www at Cloudflare. A canonical pointing at a redirect
|
||||
// is a wasted signal, so every absolute URL we emit must already be www.
|
||||
expect(SITE_URL).toBe('https://www.schoolcompare.co.uk');
|
||||
});
|
||||
|
||||
it('has no trailing slash, so joins never double up', () => {
|
||||
expect(SITE_URL.endsWith('/')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('joins a rooted path', () => {
|
||||
expect(absoluteUrl('/rankings')).toBe('https://www.schoolcompare.co.uk/rankings');
|
||||
});
|
||||
|
||||
it('joins a path missing its leading slash', () => {
|
||||
expect(absoluteUrl('rankings')).toBe('https://www.schoolcompare.co.uk/rankings');
|
||||
});
|
||||
|
||||
it('maps the site root to a bare trailing slash', () => {
|
||||
expect(absoluteUrl('/')).toBe('https://www.schoolcompare.co.uk/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The single seam between the characterization tests and the component tree.
|
||||
*
|
||||
* This file is the ONLY thing the server/client split was allowed to change.
|
||||
* It now renders the shell + server-sections composition that
|
||||
* app/school/[slug]/page.tsx builds, instead of the old monolithic views.
|
||||
* Every assertion in schoolDetail.characterization.test.tsx is unchanged —
|
||||
* that is the proof the refactor preserved behaviour.
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import { nationalAveragesFixture } from './schoolFixtures';
|
||||
|
||||
// The shell calls useComparison(), which throws outside the provider. In the
|
||||
// app this wrapper comes from app/layout.tsx.
|
||||
function withProviders(ui: ReactNode) {
|
||||
return <ComparisonProvider>{ui}</ComparisonProvider>;
|
||||
}
|
||||
|
||||
export function renderSchoolDetail(fixture: any) {
|
||||
const flags = computeSchoolFlags(fixture);
|
||||
const navItems = buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
admissionDistance: fixture.admissionDistance ?? null,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
return render(
|
||||
withProviders(
|
||||
<SchoolDetailShell
|
||||
schoolInfo={fixture.schoolInfo}
|
||||
yearlyData={fixture.yearlyData}
|
||||
census={fixture.census}
|
||||
navItems={navItems}
|
||||
>
|
||||
<PrimarySchoolSections
|
||||
{...fixture}
|
||||
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
</SchoolDetailShell>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSecondarySchoolDetail(fixture: any) {
|
||||
const flags = computeSecondaryFlags(fixture);
|
||||
const navItems = buildSecondaryNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
admissionDistance: fixture.admissionDistance ?? null,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
return render(
|
||||
withProviders(
|
||||
<SchoolDetailShell
|
||||
schoolInfo={fixture.schoolInfo}
|
||||
yearlyData={fixture.yearlyData}
|
||||
census={fixture.census}
|
||||
navItems={navItems}
|
||||
>
|
||||
<SecondarySchoolSections
|
||||
{...fixture}
|
||||
admissionsHistory={fixture.admissionsHistory ?? []}
|
||||
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
</SchoolDetailShell>,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Fixtures for the school detail characterization tests.
|
||||
*
|
||||
* Four shapes, each pinning a branch the detail views take:
|
||||
* primary — KS2 results, legacy OEIF Ofsted, 3 years of admissions
|
||||
* (enough for the year/trend toggle)
|
||||
* secondary — KS4 results, Report Card Ofsted, 1 admissions year
|
||||
* (no toggle)
|
||||
* allThrough — BOTH KS2 and KS4, so the primary content is gated back on
|
||||
* special — a special school with an all-zero KS2 row, where the
|
||||
* England comparison must be suppressed
|
||||
*
|
||||
* Field names come from lib/types.ts. If TypeScript rejects something here,
|
||||
* the fixture is wrong — never widen the type to accommodate a fixture.
|
||||
*/
|
||||
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders — SchoolResult has 57 required nullable fields, so every fixture
|
||||
// starts from an all-null row and overrides only what it is testing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const emptyResult: SchoolResult = {
|
||||
school_id: 1,
|
||||
year: 2024,
|
||||
total_pupils: null, eligible_pupils: null,
|
||||
rwm_expected_pct: null, reading_expected_pct: null, writing_expected_pct: null,
|
||||
maths_expected_pct: null, gps_expected_pct: null, science_expected_pct: null,
|
||||
rwm_high_pct: null, reading_high_pct: null, writing_high_pct: null,
|
||||
maths_high_pct: null, gps_high_pct: null,
|
||||
reading_progress: null, writing_progress: null, maths_progress: null,
|
||||
reading_avg_score: null, maths_avg_score: null, gps_avg_score: null,
|
||||
disadvantaged_pct: null, eal_pct: null, sen_support_pct: null, sen_ehcp_pct: null,
|
||||
stability_pct: null,
|
||||
reading_absence_pct: null, gps_absence_pct: null, maths_absence_pct: null,
|
||||
writing_absence_pct: null, science_absence_pct: null,
|
||||
rwm_expected_boys_pct: null, rwm_expected_girls_pct: null,
|
||||
rwm_high_boys_pct: null, rwm_high_girls_pct: null,
|
||||
rwm_expected_disadvantaged_pct: null, rwm_expected_non_disadvantaged_pct: null,
|
||||
disadvantaged_gap: null,
|
||||
rwm_expected_3yr_pct: null, reading_avg_3yr: null, maths_avg_3yr: null,
|
||||
attainment_8_score: null, progress_8_score: null,
|
||||
progress_8_lower_ci: null, progress_8_upper_ci: null,
|
||||
progress_8_english: null, progress_8_maths: null,
|
||||
progress_8_ebacc: null, progress_8_open: null,
|
||||
english_maths_strong_pass_pct: null, english_maths_standard_pass_pct: null,
|
||||
ebacc_entry_pct: null, ebacc_strong_pass_pct: null, ebacc_standard_pass_pct: null,
|
||||
ebacc_avg_score: null, gcse_grade_91_pct: null, prior_attainment_avg: null,
|
||||
};
|
||||
|
||||
export function makeResult(overrides: Partial<SchoolResult> = {}): SchoolResult {
|
||||
return { ...emptyResult, ...overrides };
|
||||
}
|
||||
|
||||
function makeSchool(overrides: Partial<School> = {}): School {
|
||||
return {
|
||||
urn: 123456,
|
||||
school_name: 'Test Primary School',
|
||||
local_authority: 'Westshire',
|
||||
local_authority_code: 900,
|
||||
school_type: 'Community school',
|
||||
school_type_code: 'CY',
|
||||
religious_denomination: 'None',
|
||||
age_range: '4-11',
|
||||
address1: '1 Test Lane',
|
||||
address2: null,
|
||||
town: 'Testville',
|
||||
postcode: 'TE1 1ST',
|
||||
address: '1 Test Lane, Testville, TE1 1ST',
|
||||
latitude: 51.5,
|
||||
longitude: -0.12,
|
||||
phase: 'Primary',
|
||||
gender: 'Mixed',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const emptyOfsted: OfstedInspection = {
|
||||
framework: null,
|
||||
inspection_date: null,
|
||||
inspection_type: null,
|
||||
overall_effectiveness: null,
|
||||
quality_of_education: null,
|
||||
behaviour_attitudes: null,
|
||||
personal_development: null,
|
||||
leadership_management: null,
|
||||
early_years_provision: null,
|
||||
previous_overall: null,
|
||||
rc_safeguarding_met: null,
|
||||
rc_inclusion: null,
|
||||
rc_curriculum_teaching: null,
|
||||
rc_achievement: null,
|
||||
rc_attendance_behaviour: null,
|
||||
rc_personal_development: null,
|
||||
rc_leadership_governance: null,
|
||||
rc_early_years: null,
|
||||
rc_sixth_form: null,
|
||||
report_url: null,
|
||||
};
|
||||
|
||||
function makeOfsted(overrides: Partial<OfstedInspection> = {}): OfstedInspection {
|
||||
return { ...emptyOfsted, ...overrides };
|
||||
}
|
||||
|
||||
function makeAdmissions(overrides: Partial<SchoolAdmissions> = {}): SchoolAdmissions {
|
||||
return {
|
||||
year: 2024,
|
||||
places_offered: 60,
|
||||
total_applications: 210,
|
||||
first_preference_applications: 90,
|
||||
first_preference_offers: 54,
|
||||
first_preference_offer_pct: 60,
|
||||
oversubscribed: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const census: SchoolCensus = {
|
||||
year: 2024,
|
||||
total_pupils: 420,
|
||||
female_pupils: 205,
|
||||
male_pupils: 215,
|
||||
fsm_pct: 18.4,
|
||||
eal_pct: 22.1,
|
||||
};
|
||||
|
||||
const absence: AbsenceData = {
|
||||
overall_absence_rate: 5.2,
|
||||
persistent_absence_rate: 14.8,
|
||||
};
|
||||
|
||||
const deprivation: SchoolDeprivation = {
|
||||
lsoa_code: 'E01000001',
|
||||
idaci_score: 0.21,
|
||||
idaci_decile: 4,
|
||||
};
|
||||
|
||||
const finance: SchoolFinance = {
|
||||
year: 2024,
|
||||
per_pupil_spend: 5400,
|
||||
staff_cost_pct: 72.5,
|
||||
teacher_cost_pct: 51.2,
|
||||
support_staff_cost_pct: 21.3,
|
||||
premises_cost_pct: 6.1,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// National averages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const nationalAveragesFixture: NationalAverages = {
|
||||
year: 2024,
|
||||
primary: { rwm_expected_pct: 61, rwm_high_pct: 8, reading_expected_pct: 74 },
|
||||
secondary: { attainment_8_score: 45.9, progress_8_score: 0, ebacc_entry_pct: 39 },
|
||||
by_year: [
|
||||
{ year: 2023, primary: { rwm_expected_pct: 60 }, secondary: { attainment_8_score: 44.4 } },
|
||||
{ year: 2024, primary: { rwm_expected_pct: 61 }, secondary: { attainment_8_score: 45.9 } },
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 1: ordinary primary — KS2 results, legacy OEIF, 3 admissions years
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const primaryFixture = {
|
||||
schoolInfo: makeSchool(),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2022, rwm_expected_pct: 54, reading_expected_pct: 68, writing_expected_pct: 62, maths_expected_pct: 65, total_pupils: 58 }),
|
||||
makeResult({ year: 2023, rwm_expected_pct: 56, reading_expected_pct: 70, writing_expected_pct: 64, maths_expected_pct: 67, total_pupils: 60 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 58, rwm_high_pct: 9,
|
||||
reading_expected_pct: 72, writing_expected_pct: 66, maths_expected_pct: 69,
|
||||
reading_progress: 0.4, writing_progress: -0.2, maths_progress: 1.1,
|
||||
disadvantaged_pct: 24.5, eal_pct: 22.1, sen_support_pct: 13.2,
|
||||
total_pupils: 60,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'OEIF',
|
||||
inspection_date: '2023-05-17',
|
||||
inspection_type: 'Section 5',
|
||||
overall_effectiveness: 2,
|
||||
quality_of_education: 2,
|
||||
behaviour_attitudes: 1,
|
||||
personal_development: 2,
|
||||
leadership_management: 2,
|
||||
previous_overall: 3,
|
||||
grade_source: 'graded',
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024 }),
|
||||
admissionsHistory: [
|
||||
makeAdmissions({ year: 2022, first_preference_offer_pct: 71 }),
|
||||
makeAdmissions({ year: 2023, first_preference_offer_pct: 65 }),
|
||||
makeAdmissions({ year: 2024, first_preference_offer_pct: 60 }),
|
||||
],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 2: secondary — KS4 results, Report Card Ofsted, single admissions year
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const secondaryFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 234567,
|
||||
school_name: 'Test Secondary School',
|
||||
phase: 'Secondary',
|
||||
age_range: '11-16',
|
||||
school_type: 'Academy converter',
|
||||
school_type_code: 'AC',
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2023, attainment_8_score: 44.8, progress_8_score: 0.18 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
attainment_8_score: 46.2, progress_8_score: 0.31,
|
||||
progress_8_banding: 'Above average',
|
||||
english_maths_standard_pass_pct: 68.4,
|
||||
english_maths_strong_pass_pct: 47.1,
|
||||
ebacc_entry_pct: 41.2, ebacc_standard_pass_pct: 28.6,
|
||||
ebacc_strong_pass_pct: 19.4, ebacc_avg_score: 4.31,
|
||||
disadvantaged_pct: 27.8, eal_pct: 19.3, sen_support_pct: 11.7,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'ReportCard',
|
||||
inspection_date: '2025-11-20',
|
||||
rc_inspection_date: '2025-11-20',
|
||||
// isReportCard keys off a non-empty report_card record, NOT the rc_*
|
||||
// fields — the rc_* numbers supply the values once the layout is chosen.
|
||||
report_card: {
|
||||
inclusion: { code: 2, label: 'Strong' },
|
||||
curriculum_teaching: { code: 2, label: 'Strong' },
|
||||
achievement: { code: 3, label: 'Expected standard' },
|
||||
attendance_behaviour: { code: 2, label: 'Strong' },
|
||||
personal_development: { code: 1, label: 'Exceptional' },
|
||||
leadership_governance: { code: 2, label: 'Strong' },
|
||||
},
|
||||
rc_safeguarding_met: true,
|
||||
rc_inclusion: 2,
|
||||
rc_curriculum_teaching: 2,
|
||||
rc_achievement: 3,
|
||||
rc_attendance_behaviour: 2,
|
||||
rc_personal_development: 1,
|
||||
rc_leadership_governance: 2,
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
|
||||
admissionsHistory: [makeAdmissions({ year: 2024, school_phase: 'Secondary' })],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 3: all-through — BOTH key stages on the same row.
|
||||
// The highest-value fixture: isAllThrough gates the KS2 content back on for a
|
||||
// school that also has KS4 data, and this is the case most likely to break.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const allThroughFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 345678,
|
||||
school_name: 'Test All-Through School',
|
||||
phase: 'All-through',
|
||||
age_range: '4-18',
|
||||
has_sixth_form: true,
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({ year: 2023, rwm_expected_pct: 55, attainment_8_score: 45.0 }),
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 63, reading_expected_pct: 76,
|
||||
writing_expected_pct: 70, maths_expected_pct: 71,
|
||||
attainment_8_score: 48.7, progress_8_score: 0.42,
|
||||
english_maths_standard_pass_pct: 71.2,
|
||||
disadvantaged_pct: 20.1, eal_pct: 17.5, sen_support_pct: 9.8,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: makeOfsted({
|
||||
framework: 'OEIF',
|
||||
inspection_date: '2022-10-04',
|
||||
inspection_type: 'Section 5',
|
||||
overall_effectiveness: 1,
|
||||
quality_of_education: 1,
|
||||
behaviour_attitudes: 1,
|
||||
personal_development: 1,
|
||||
leadership_management: 1,
|
||||
grade_source: 'graded',
|
||||
}),
|
||||
census,
|
||||
admissions: makeAdmissions({ year: 2024, school_phase: 'Secondary' }),
|
||||
admissionsHistory: [
|
||||
makeAdmissions({ year: 2023, school_phase: 'Secondary', first_preference_offer_pct: 58 }),
|
||||
makeAdmissions({ year: 2024, school_phase: 'Secondary', first_preference_offer_pct: 52 }),
|
||||
],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture 4: special school — all-zero KS2 row.
|
||||
// Guards the PR #70 regression: special/PRU/AP schools must not be shown an
|
||||
// England comparison, because their pupils sit the same tests but very few
|
||||
// reach the mainstream "expected standard".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const specialFixture = {
|
||||
schoolInfo: makeSchool({
|
||||
urn: 456789,
|
||||
school_name: 'Test Special School',
|
||||
school_type: 'Community special school',
|
||||
school_type_code: 'CYS',
|
||||
age_range: '2-19',
|
||||
}),
|
||||
yearlyData: [
|
||||
makeResult({
|
||||
year: 2024,
|
||||
rwm_expected_pct: 0,
|
||||
reading_expected_pct: 0,
|
||||
writing_expected_pct: 0,
|
||||
maths_expected_pct: 0,
|
||||
total_pupils: 12,
|
||||
}),
|
||||
],
|
||||
absenceData: absence,
|
||||
ofsted: null,
|
||||
census,
|
||||
admissions: null,
|
||||
admissionsHistory: [],
|
||||
deprivation,
|
||||
finance,
|
||||
};
|
||||
@@ -1,12 +1,16 @@
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
import { AdmissionsView } from '@/components/AdmissionsView';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'School Admissions Guide',
|
||||
// Deadlines and offer days are what gets searched, and what this page is
|
||||
// genuinely best at — the countdowns are live.
|
||||
title: { absolute: 'School Admissions Deadlines & Offer Days | schoolcompare' },
|
||||
description:
|
||||
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
|
||||
'Every key date for primary and secondary school admissions in England, with live countdowns to the application deadline and National Offer Day.',
|
||||
alternates: { canonical: absoluteUrl('/admissions') },
|
||||
};
|
||||
|
||||
export default function AdmissionsPage() {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -5,6 +5,7 @@
|
||||
|
||||
import { fetchComparison, fetchMetrics } from '@/lib/api';
|
||||
import { ComparisonView } from '@/components/ComparisonView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface ComparePageProps {
|
||||
@@ -14,13 +15,36 @@ interface ComparePageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Compare Schools',
|
||||
description:
|
||||
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
||||
keywords:
|
||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||
};
|
||||
/**
|
||||
* Indexability depends on the query string, so this cannot be a static export.
|
||||
*
|
||||
* Bare /compare is the landing page for the "compare schools" head term and
|
||||
* stays indexable. /compare?urns=… is an unbounded parameter space — 25,193
|
||||
* schools make ~317 million pairs — so it goes noindex. It stays `follow` and
|
||||
* keeps a canonical to the bare path, so the links out to each school page
|
||||
* still count.
|
||||
*/
|
||||
export async function generateMetadata(
|
||||
{ searchParams }: ComparePageProps,
|
||||
): Promise<Metadata> {
|
||||
const { urns } = await searchParams;
|
||||
|
||||
const base: Metadata = {
|
||||
// Deliberately not the homepage's phrase. Two pages chasing "compare
|
||||
// schools" is how a site competes with itself; this one takes the tool
|
||||
// phrasing instead.
|
||||
title: 'School Comparison Tool — Up to Five at Once | schoolcompare',
|
||||
description:
|
||||
'Put up to five English schools in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.',
|
||||
keywords:
|
||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||
alternates: { canonical: absoluteUrl('/compare') },
|
||||
};
|
||||
|
||||
if (!urns) return base;
|
||||
|
||||
return { ...base, robots: { index: false, follow: true } };
|
||||
}
|
||||
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
// can still use Next.js's per-call revalidate cache.
|
||||
|
||||
+471
-1919
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
+47
-29
@@ -1,23 +1,29 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { DM_Sans, Playfair_Display } from 'next/font/google';
|
||||
import { Manrope, Inter } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import { SITE_URL } from '@/lib/site';
|
||||
import './globals.css';
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
// Manrope carries headings and key messaging — the guideline's "friendly,
|
||||
// modern personality". It never sets body copy or a control.
|
||||
const manrope = Manrope({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-dm-sans',
|
||||
weight: ['500', '600', '700'],
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const playfairDisplay = Playfair_Display({
|
||||
// Inter carries body copy, every control and every figure — chosen in the
|
||||
// guideline for clarity and legibility. Its tabular numerals hold a column
|
||||
// properly, which matters on a page that is mostly percentages.
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: ['600', '700'],
|
||||
variable: '--font-playfair',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
@@ -27,42 +33,47 @@ export const viewport: Viewport = {
|
||||
// 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',
|
||||
// These must match --bg-primary in globals.css for each theme, or the
|
||||
// browser chrome and the page disagree at the top edge of the screen.
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
|
||||
{ media: '(prefers-color-scheme: light)', color: '#FAFAF8' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#111A20' },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'SchoolCompare',
|
||||
title: 'schoolcompare',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
title: {
|
||||
default: 'SchoolCompare | Compare School Performance',
|
||||
template: '%s | SchoolCompare',
|
||||
default: 'Compare Schools Side by Side | schoolcompare',
|
||||
template: '%s | schoolcompare',
|
||||
},
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||
authors: [{ name: 'SchoolCompare' }],
|
||||
authors: [{ name: 'schoolcompare' }],
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/favicon.svg',
|
||||
shortcut: '/favicon.svg',
|
||||
apple: '/favicon.svg',
|
||||
},
|
||||
// No `icons` key on purpose: setting it here would override the file
|
||||
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
|
||||
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
||||
metadataBase: new URL(SITE_URL),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
url: 'https://schoolcompare.co.uk',
|
||||
siteName: 'SchoolCompare',
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
url: SITE_URL,
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
// summary_large_image now that there is an image worth showing.
|
||||
card: 'summary_large_image',
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,7 +83,15 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
// The font variable classes must sit on <html>, not <body>. globals.css
|
||||
// declares --font-display on :root as var(--font-manrope) and --font-ui as
|
||||
// var(--font-inter), and a custom property's var() references resolve on
|
||||
// the element that declares it. With the classes on <body>, those two are
|
||||
// undefined at :root, so --font-display computes to the guaranteed-invalid
|
||||
// value and every font-family referencing it silently falls back — which
|
||||
// once shipped the whole site in Times. Nothing throws and no unit test
|
||||
// fails, so the e2e typeface assertion is the only guard.
|
||||
<html lang="en" className={`${manrope.variable} ${inter.variable}`}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||
@@ -94,8 +113,7 @@ export default function RootLayout({
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
||||
<div className="noise-overlay" />
|
||||
<body>
|
||||
<ComparisonProvider>
|
||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||
<Navigation />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
/**
|
||||
* The site had no og:image at all, so every link pasted into a class WhatsApp
|
||||
* group or a Slack channel rendered as a bare grey text card — on the highest
|
||||
* intent channel this product has.
|
||||
*
|
||||
* The card is the brand's own proposition on the Sand ground, with the mark
|
||||
* and a suggestion of the illustration's rolling landscape along the bottom.
|
||||
* It states the promise rather than decorating it.
|
||||
*/
|
||||
export const alt = 'schoolcompare — find the school where your child will flourish';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
const SAND = '#F5EFE6';
|
||||
const INK = '#1C2731';
|
||||
const MUTED = '#5F6A75';
|
||||
const TEAL = '#0F766E';
|
||||
const SAGE = '#A7D7C5';
|
||||
const SAGE_DEEP = '#7FC3AC';
|
||||
const SAGE_PALE = '#D6EDE2';
|
||||
|
||||
async function font(file: string) {
|
||||
return readFile(join(process.cwd(), 'assets', file));
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark, as a data URI. Satori has no access to the public/ URL space, so
|
||||
* the artwork is read off disk and inlined. This is the same file the header
|
||||
* serves — public/brand/mark.png — so the card can never drift from the site.
|
||||
* The 47×56 box below is that file's 176:208 ratio; re-crop the artwork and
|
||||
* this has to move with it, or Satori will stretch it.
|
||||
*/
|
||||
async function markDataUri() {
|
||||
const png = await readFile(join(process.cwd(), 'public', 'brand', 'mark.png'));
|
||||
return `data:image/png;base64,${png.toString('base64')}`;
|
||||
}
|
||||
|
||||
export default async function OpengraphImage() {
|
||||
const [regular, bold, mark] = await Promise.all([
|
||||
font('Manrope-Regular.ttf'),
|
||||
font('Manrope-Bold.ttf'),
|
||||
markDataUri(),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
background: SAND,
|
||||
padding: '58px 72px 0',
|
||||
fontFamily: 'Manrope',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Rolling hills along the bottom edge — the illustration style,
|
||||
reduced to the one gesture that survives at thumbnail size. */}
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 210, display: 'flex' }}>
|
||||
<svg width="1200" height="210" viewBox="0 0 1200 210">
|
||||
<path d="M0 92 C 200 56 380 100 560 78 C 760 54 940 92 1200 62 L1200 210 L0 210 Z" fill={SAGE_PALE} />
|
||||
<path d="M0 132 C 230 96 420 140 620 118 C 830 94 1000 130 1200 106 L1200 210 L0 210 Z" fill={SAGE} />
|
||||
<path d="M0 176 C 260 146 470 186 690 166 C 900 146 1040 174 1200 158 L1200 210 L0 210 Z" fill={SAGE_DEEP} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Lockup */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={mark} width={47} height={56} alt="" />
|
||||
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
|
||||
<span style={{ color: INK }}>school</span>
|
||||
<span style={{ color: TEAL }}>compare</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proposition. Sized so the headline holds two lines and the subhead
|
||||
one — an orphaned word reads as an accident at this size. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, paddingBottom: 236 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.035em',
|
||||
lineHeight: 1.05,
|
||||
color: INK,
|
||||
maxWidth: 900,
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 18 }}>Find the school where</span>
|
||||
<span style={{ marginRight: 18 }}>your child will</span>
|
||||
<span style={{ color: TEAL }}>flourish.</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 27, color: MUTED, letterSpacing: '-0.005em' }}>
|
||||
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{ name: 'Manrope', data: regular, weight: 400, style: 'normal' },
|
||||
{ name: 'Manrope', data: bold, weight: 700, style: 'normal' },
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
+49
-6
@@ -3,7 +3,10 @@
|
||||
* Main landing page with school search and browsing
|
||||
*/
|
||||
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { HomeView } from '@/components/HomeView';
|
||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||
import { EditorialSection } from '@/components/EditorialSection';
|
||||
@@ -24,9 +27,32 @@ interface HomePageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Home',
|
||||
description: 'Search and compare school performance across England',
|
||||
/*
|
||||
* `title` was the bare string 'Home', which is what the browser tab, the
|
||||
* bookmark and the search result all read. `absolute` opts out of the root
|
||||
* layout's "%s | schoolcompare" template so the homepage doesn't end up
|
||||
* saying the brand twice.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
/*
|
||||
* Intent in the title, differentiator in the description.
|
||||
*
|
||||
* These queries are owned by the DfE's own "Compare school performance"
|
||||
* service, and the old title — brand first, then a near-paraphrase of that
|
||||
* service's name — gave a searcher no reason to pick us over it. It drew
|
||||
* 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same
|
||||
* neighbourhood, so the ranking was never the problem.
|
||||
*
|
||||
* The title now matches what people type. The description carries the one
|
||||
* fact gov.uk does not publish: how close you had to live to get a place.
|
||||
*/
|
||||
title: { absolute: 'Compare Schools Side by Side | schoolcompare' },
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||
// This page reads eleven search params. They filter a result set; they do
|
||||
// not make a new document. Collapsing every combination onto "/" stops the
|
||||
// homepage competing with itself for its own head terms.
|
||||
alternates: { canonical: absoluteUrl('/') },
|
||||
};
|
||||
|
||||
// The page reads searchParams, which makes rendering dynamic by default.
|
||||
@@ -79,14 +105,24 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
}
|
||||
|
||||
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
const total = dataInfo?.total_schools ?? null;
|
||||
// `unique_schools`, not `total_schools` — the latter is not a field this
|
||||
// endpoint returns, and reading it silently yielded null on every request.
|
||||
const total = dataInfo?.unique_schools ?? null;
|
||||
const years = dataInfo?.years_available ?? [];
|
||||
return (
|
||||
<HomeView
|
||||
initialSchools={schoolsData}
|
||||
filters={resolvedFilters}
|
||||
totalSchools={total}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={total}
|
||||
localAuthorityCount={resolvedFilters.local_authorities.length}
|
||||
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
|
||||
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -99,7 +135,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
filters={emptyFilters}
|
||||
totalSchools={null}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={null}
|
||||
localAuthorityCount={0}
|
||||
earliestYearLabel={null}
|
||||
latestYearLabel={null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { fetchRankings, fetchFilters, fetchMetrics } from '@/lib/api';
|
||||
import { RankingsView } from '@/components/RankingsView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface RankingsPageProps {
|
||||
@@ -17,9 +18,15 @@ interface RankingsPageProps {
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'School Rankings',
|
||||
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
||||
// 'School Rankings' matched nothing anyone types. League tables is the
|
||||
// phrase parents actually search, and it spikes each results day.
|
||||
title: { absolute: 'Primary & Secondary School League Tables | schoolcompare' },
|
||||
description:
|
||||
'Rank English schools by SATs results, GCSEs, Progress 8 or Attainment 8, and filter by local authority or year. Built from the DfE’s own figures.',
|
||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||
// Param forms (?metric=&local_authority=&year=&phase=) collapse here for
|
||||
// now. W3 replaces them with real indexable paths.
|
||||
alternates: { canonical: absoluteUrl('/rankings') },
|
||||
};
|
||||
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { MetadataRoute } from 'next';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
@@ -14,6 +15,6 @@ export default function robots(): MetadataRoute.Robots {
|
||||
disallow: ['/api/', '/_next/'],
|
||||
},
|
||||
],
|
||||
sitemap: 'https://schoolcompare.co.uk/sitemap.xml',
|
||||
sitemap: absoluteUrl('/sitemap.xml'),
|
||||
};
|
||||
}
|
||||
@@ -4,11 +4,18 @@
|
||||
* URL format: /school/138267-school-name-here
|
||||
*/
|
||||
|
||||
import { fetchSchoolDetails, fetchSchools } from '@/lib/api';
|
||||
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||
import type { NationalAverages } from '@/lib/types';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -91,8 +98,8 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
siteName: 'SchoolCompare',
|
||||
url: absoluteUrl(canonicalPath),
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
@@ -100,7 +107,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
description,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
canonical: absoluteUrl(canonicalPath),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
@@ -124,16 +131,24 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch school data
|
||||
// Fetch school data. National averages feed the England-comparison deltas
|
||||
// across most sections; fetching them here rather than in a client effect
|
||||
// keeps those sections server-renderable and puts the deltas in the initial
|
||||
// HTML. They are supplementary, so they degrade to null rather than 404ing
|
||||
// the page.
|
||||
let data;
|
||||
let nationalAvg: NationalAverages | null = null;
|
||||
try {
|
||||
data = await fetchSchoolDetails(urn);
|
||||
[data, nationalAvg] = await Promise.all([
|
||||
fetchSchoolDetails(urn),
|
||||
fetchNationalAverages().catch(() => null),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -143,13 +158,32 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
|
||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||
const isAllThrough = phaseStr === 'all-through';
|
||||
// All-through schools go to SchoolDetailView (renders both KS2 + KS4 sections).
|
||||
// SecondarySchoolDetailView is KS4-only, so all-through schools would lose SATs data.
|
||||
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
|
||||
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
|
||||
const isSecondary = !isAllThrough && (
|
||||
phaseStr.includes('secondary')
|
||||
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
||||
);
|
||||
|
||||
// Section list is computed on the server so the client shell never needs to
|
||||
// derive it -- and so it can never disagree with what the sections render.
|
||||
const sectionInput = {
|
||||
schoolInfo: school_info, yearlyData: yearly_data,
|
||||
absenceData: absence_data, census: census ?? null,
|
||||
deprivation: deprivation ?? null, finance: finance ?? null,
|
||||
};
|
||||
const primaryFlags = computeSchoolFlags(sectionInput);
|
||||
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
||||
const navInput = {
|
||||
ofsted: ofsted ?? null,
|
||||
admissions: admissions ?? null,
|
||||
admissionDistance: admission_distance ?? null,
|
||||
hasLocation: school_info.latitude != null && school_info.longitude != null,
|
||||
yearlyDataLength: yearly_data.length,
|
||||
};
|
||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
||||
|
||||
// Generate JSON-LD structured data for SEO
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
@@ -184,28 +218,49 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
{isSecondary ? (
|
||||
<SecondarySchoolDetailView
|
||||
<SchoolDetailShell
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
/>
|
||||
navItems={secondaryNavItems}
|
||||
>
|
||||
<SecondarySchoolSections
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
flags={secondaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
) : (
|
||||
<SchoolDetailView
|
||||
<SchoolDetailShell
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
/>
|
||||
navItems={primaryNavItems}
|
||||
>
|
||||
<PrimarySchoolSections
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
flags={primaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Phase variants of a place page.
|
||||
*
|
||||
* Phase is part of the query — "primary schools in beccles", "secondary
|
||||
* schools in brentwood" — not a filter applied afterwards, so each gets its
|
||||
* own indexable path. A place with no schools of the phase has no page: the
|
||||
* per-phase threshold, not an error.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ place: string; phase: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
const PHASES = ['primary', 'secondary'] as const;
|
||||
type Phase = (typeof PHASES)[number];
|
||||
|
||||
const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v);
|
||||
|
||||
async function resolve(slug: string, phase: Phase) {
|
||||
return (await fetchPlace('town', slug, phase))
|
||||
?? (await fetchPlace('locality', slug, phase));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { place: slug, phase } = await params;
|
||||
if (!isPhase(phase)) return { title: 'Place Not Found' };
|
||||
const detail = await resolve(slug, phase);
|
||||
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
|
||||
|
||||
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
|
||||
const { name } = detail.place;
|
||||
return {
|
||||
title: { absolute: `${word} Schools in ${name} — Ranked | schoolcompare` },
|
||||
description:
|
||||
`Every ${phase} school in ${name} ranked by results, with Ofsted grades and `
|
||||
+ `the local average against England.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/${slug}/${phase}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PlacePhasePage({ params }: Props) {
|
||||
const { place: slug, phase } = await params;
|
||||
if (!isPhase(phase)) notFound();
|
||||
const detail = await resolve(slug, phase);
|
||||
if (!detail || detail.schools.length === 0) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
const englandAverage = phase === 'secondary'
|
||||
? national?.secondary?.attainment_8_score ?? null
|
||||
: national?.primary?.rwm_expected_pct ?? null;
|
||||
|
||||
return <PlaceView detail={detail} phase={phase}
|
||||
englandAverage={englandAverage} neighbours={[]} />;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Town and locality pages.
|
||||
*
|
||||
* A place below the five-school threshold is not in the registry, so
|
||||
* fetchPlace returns null and the request 404s rather than rendering a page
|
||||
* with nothing to say.
|
||||
*/
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces, authoritySlug } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ place: string }> }
|
||||
|
||||
// ISR: place aggregates change only when the pipeline runs.
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ place: string }>> {
|
||||
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
|
||||
// Matches the PRERENDER_SCHOOLS gate on the school route.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'town' || p.kind === 'locality')
|
||||
.map((p) => ({ place: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(slug: string) {
|
||||
return (await fetchPlace('town', slug)) ?? (await fetchPlace('locality', slug));
|
||||
}
|
||||
|
||||
/** Other towns in the same authority — the cheapest honest definition of
|
||||
* "nearby", and enough to stop each place page being a dead end. */
|
||||
async function neighboursOf(detail: { place: { slug: string; parent_authority: string | null } }) {
|
||||
if (!detail.place.parent_authority) return [];
|
||||
const all = await fetchPlaces();
|
||||
return all
|
||||
.filter((p) => p.kind === 'town' && p.slug !== detail.place.slug)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { place: slug } = await params;
|
||||
const detail = await resolve(slug);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
// absolute: the root layout's template appends '| schoolcompare' to a
|
||||
// plain string, and this title already carries it. Without this every
|
||||
// place title read '... | schoolcompare | schoolcompare'.
|
||||
title: { absolute: `Schools in ${name} — Compare ${count} Schools | schoolcompare` },
|
||||
description:
|
||||
`Every school in ${name} ranked by SATs and GCSE results, with Ofsted grades, `
|
||||
+ `the local average against England, and how close you had to live to get a place.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/${slug}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PlacePage({ params }: Props) {
|
||||
const { place: slug } = await params;
|
||||
const detail = await resolve(slug);
|
||||
if (!detail) notFound();
|
||||
|
||||
// Global constraint: no page without a local average. A place with too few
|
||||
// schools carrying results has nothing to say that a list does not, so it
|
||||
// defers to its authority rather than publishing a thin page.
|
||||
if (detail.averages.rwm_expected_pct == null
|
||||
&& detail.averages.attainment_8_score == null) {
|
||||
if (detail.place.parent_authority) {
|
||||
redirect(`/schools/authority/${authoritySlug(detail.place.parent_authority)}`);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
// NationalAverages is nested by phase — { primary: {...}, secondary: {...} }
|
||||
// — not flat. Reading it flat silently yields undefined and the page renders
|
||||
// with no comparison, which is the one thing that makes it not a list.
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={await neighboursOf(detail)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Local authority pages.
|
||||
*
|
||||
* A separate namespace from /schools/[place] because 67 town names collide
|
||||
* with an authority name and neither set contains the other — Bedford the
|
||||
* town holds 104 schools, Bedford the authority 86, because postal towns
|
||||
* cross authority boundaries. The title says "Local Authority" so a reader
|
||||
* landing on both knows which set each covers.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ la: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ la: string }>> {
|
||||
// Gated like every other prerender in this app. There are only ~154
|
||||
// authorities, but "few enough to always build" still means the API must be
|
||||
// reachable at build time, and in CI it is not — the build fails with
|
||||
// ECONNREFUSED rather than degrading. The catch is the same fallback the
|
||||
// school route uses.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'authority')
|
||||
.map((p) => ({ la: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { la } = await params;
|
||||
const detail = await fetchPlace('authority', la);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
title: { absolute: `Schools in ${name} — Local Authority | schoolcompare` },
|
||||
description:
|
||||
`All ${count} schools in the ${name} local authority, ranked by SATs and GCSE `
|
||||
+ `results, with Ofsted grades and the authority average against England.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/authority/${la}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AuthorityPage({ params }: Props) {
|
||||
const { la } = await params;
|
||||
const detail = await fetchPlace('authority', la);
|
||||
if (!detail) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Postcode district pages.
|
||||
*
|
||||
* No phase variants: nobody searches "primary schools in SW11", so the
|
||||
* variants would be pages without demand. These exist to catch
|
||||
* "schools near <postcode>" and to give London districts a geographic page
|
||||
* where the GIAS town field cannot.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ outcode: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ outcode: string }>> {
|
||||
// 1,760 of these; same CI budget argument as the town routes.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'outcode')
|
||||
.map((p) => ({ outcode: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { outcode } = await params;
|
||||
const detail = await fetchPlace('outcode', outcode);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
title: { absolute: `Schools near ${name} | schoolcompare` },
|
||||
description:
|
||||
`${count} schools in the ${name} postcode district, ranked by results, with `
|
||||
+ `Ofsted grades and how close you had to live to get a place.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/near/${outcode}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OutcodePage({ params }: Props) {
|
||||
const { outcode } = await params;
|
||||
const detail = await fetchPlace('outcode', outcode);
|
||||
if (!detail) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,8 @@
|
||||
/**
|
||||
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
|
||||
*
|
||||
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
|
||||
* baking the backend host into the build, so one image works in every
|
||||
* environment. robots.ts points crawlers here.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { proxySitemap } from '@/lib/sitemapProxy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function backendOrigin(): string {
|
||||
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
||||
return base.replace(/\/api$/, '');
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
|
||||
} catch {
|
||||
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
|
||||
}
|
||||
|
||||
const body = await upstream.text();
|
||||
return new NextResponse(body, {
|
||||
status: upstream.status,
|
||||
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
|
||||
});
|
||||
return proxySitemap('/sitemap.xml');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { proxySitemap } from '@/lib/sitemapProxy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Children are /sitemaps/static.xml and /sitemaps/schools-{n}.xml. The name is
|
||||
* validated here rather than passed through, so this route cannot be used to
|
||||
* reach arbitrary backend paths.
|
||||
*/
|
||||
const CHILD = /^(static|schools-\d+|places-\d+|outcodes-\d+)\.xml$/;
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ parts: string[] }> },
|
||||
) {
|
||||
const { parts } = await params;
|
||||
const name = parts.join('/');
|
||||
if (!CHILD.test(name)) {
|
||||
return new NextResponse('Not found', { status: 404 });
|
||||
}
|
||||
return proxySitemap(`/sitemaps/${name}`);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
@@ -9,6 +9,7 @@
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||
import type { SchoolAdmissions } from '@/lib/types';
|
||||
@@ -38,6 +39,11 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
||||
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
||||
|
||||
// Canvas can't read var(), so pull the resolved tokens through the bridge.
|
||||
const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens(
|
||||
'--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card'
|
||||
);
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -49,7 +55,9 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 12 },
|
||||
@@ -62,7 +70,7 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
y: {
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${v}%` },
|
||||
},
|
||||
x: {
|
||||
@@ -80,14 +88,14 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
data: values,
|
||||
clip: false as const,
|
||||
spanGaps: false,
|
||||
borderColor: '#e07256',
|
||||
backgroundColor: 'rgba(224,114,86,0.10)',
|
||||
borderColor: cBrand,
|
||||
backgroundColor: alpha('--brand', 0.10),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||
pointBackgroundColor: '#e07256',
|
||||
pointBorderColor: '#fff',
|
||||
pointBackgroundColor: cBrand,
|
||||
pointBorderColor: cCard,
|
||||
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.85rem;
|
||||
padding-left: 0.85rem;
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
border-left: 2px solid var(--border-color, #e5dfd5);
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
@@ -51,33 +51,33 @@
|
||||
border-left: 2px solid transparent;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
border-left-color: var(--brand);
|
||||
}
|
||||
|
||||
.navDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.navLinkActive .navDot {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
/* ─── Hero ───────────────────────────────────────────── */
|
||||
@@ -95,8 +95,8 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(45, 125, 125, 0.1);
|
||||
color: var(--brand);
|
||||
background: rgba(var(--brand-rgb), 0.1);
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 1rem;
|
||||
@@ -107,22 +107,22 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.heroSub {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.55;
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
.countdownSection {
|
||||
padding: 0 0 2.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.countdownRail {
|
||||
@@ -155,11 +155,11 @@
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
@@ -177,12 +177,12 @@
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.chipDeadline::before { background: var(--accent-coral, #e07256); }
|
||||
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
|
||||
.chipDeadline::before { background: var(--brand); }
|
||||
.chipOffer::before { background: var(--status-above); }
|
||||
|
||||
.chipUrgent {
|
||||
border-color: rgba(224, 114, 86, 0.4);
|
||||
background: rgba(224, 114, 86, 0.04);
|
||||
border-color: rgba(var(--status-below-rgb), 0.4);
|
||||
background: rgba(var(--status-below-rgb), 0.04);
|
||||
}
|
||||
|
||||
.chipTrack {
|
||||
@@ -196,8 +196,8 @@
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.chipTrackDeadline { color: var(--accent-coral-dark, #b04a2e); }
|
||||
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
||||
.chipTrackDeadline { color: var(--brand-strong); }
|
||||
.chipTrackOffer { color: var(--status-above); }
|
||||
|
||||
.chipTrackDot {
|
||||
width: 5px;
|
||||
@@ -208,7 +208,8 @@
|
||||
}
|
||||
|
||||
.chipDays {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
@@ -217,17 +218,17 @@
|
||||
|
||||
.chipDeadline .chipDays,
|
||||
.chipUrgent .chipDays {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.chipOffer .chipDays {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.chipDaysUnit {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: bottom;
|
||||
line-height: 2;
|
||||
@@ -236,14 +237,14 @@
|
||||
.chipMilestone {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.chipDate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
@@ -251,8 +252,8 @@
|
||||
|
||||
.track {
|
||||
margin-bottom: 3rem;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -263,7 +264,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 1.75rem 2rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -278,22 +279,22 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.trackTitle {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.trackSub {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -317,13 +318,13 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.trackDateVal {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ─── Timeline ───────────────────────────────────────── */
|
||||
@@ -356,27 +357,27 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--border);
|
||||
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);
|
||||
background: rgba(var(--brand-rgb), 0.15);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.stepOffer .stepDot {
|
||||
background: rgba(45, 125, 125, 0.15);
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
background: rgba(var(--status-above-rgb), 0.15);
|
||||
border-color: var(--status-above);
|
||||
}
|
||||
|
||||
.stepLine {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@@ -395,27 +396,28 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.stepDeadline .stepDate { color: var(--accent-coral-dark, #b04a2e); }
|
||||
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
||||
.stepDeadline .stepDate { color: var(--brand-strong); }
|
||||
.stepOffer .stepDate { color: var(--status-above); }
|
||||
|
||||
.stepTitle {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.3rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
|
||||
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
|
||||
.stepDeadline .stepTitle { color: var(--brand-strong); }
|
||||
.stepOffer .stepTitle { color: var(--status-above); }
|
||||
|
||||
.stepBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -427,13 +429,13 @@
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
text-decoration: none;
|
||||
transition: gap 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.stepTool:hover {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
color: var(--brand-strong);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -455,9 +457,9 @@
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
|
||||
border: 1px solid rgba(201, 162, 39, 0.28);
|
||||
border-left: 3px solid var(--accent-gold, #c9a227);
|
||||
background: var(--status-below-bg);
|
||||
border: 1px solid rgba(var(--status-below-rgb), 0.28);
|
||||
border-left: 3px solid var(--status-below);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -467,8 +469,8 @@
|
||||
height: 1.05rem;
|
||||
margin-top: 0.1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-gold, #c9a227);
|
||||
color: #fff;
|
||||
background: var(--status-below);
|
||||
color: var(--text-inverse);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.05rem;
|
||||
@@ -482,14 +484,15 @@
|
||||
.calloutTitle {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-gold-text, #7a6800);
|
||||
color: var(--status-below);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.calloutBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -501,10 +504,10 @@
|
||||
}
|
||||
|
||||
.tipsHeading {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -515,16 +518,17 @@
|
||||
}
|
||||
|
||||
.tipCard {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tipNumber {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #7d766b; /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||
color: var(--text-muted); /* 3.8:1 on cream — AA large-text for this 28px numeral */
|
||||
line-height: 1;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
@@ -532,14 +536,15 @@
|
||||
.tipHeading {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tipBody {
|
||||
font-family: var(--font-prose);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -557,8 +562,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: 0;
|
||||
background: var(--bg-primary, #faf8f3);
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 0 -1.25rem 1rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
z-index: 10;
|
||||
@@ -590,8 +595,8 @@
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
background: var(--text-primary, #1a1612);
|
||||
color: #fff;
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ const SECONDARY_STEPS: Step[] = [
|
||||
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.',
|
||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||
},
|
||||
{
|
||||
date: '31 October',
|
||||
@@ -126,7 +126,7 @@ const PRIMARY_STEPS: Step[] = [
|
||||
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.',
|
||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||
},
|
||||
{
|
||||
date: '15 January',
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
min-height: 40px;
|
||||
min-width: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(0, 0, 0, .12);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -52,8 +52,8 @@
|
||||
}
|
||||
|
||||
.chip[aria-pressed="true"] {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
border-color: rgba(0, 0, 0, .35);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.chipDot {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { useSeriesColors, useThemeTokens } from '@/lib/theme';
|
||||
import { buildCompareChart } from '@/lib/compareChartData';
|
||||
import type { ComparisonData } from '@/lib/types';
|
||||
import {
|
||||
@@ -48,6 +49,13 @@ const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'st
|
||||
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
||||
const isMobile = useIsMobile();
|
||||
// Canvas can't resolve var(), so the series come through the token bridge.
|
||||
// Same index order as CHART_COLORS, so a school's line and its DOM swatch
|
||||
// stay the same colour.
|
||||
const seriesColors = useSeriesColors();
|
||||
const [cRef, cGrid, cInverse, cInverseText] = useThemeTokens(
|
||||
'--chart-reference', '--chart-grid', '--surface-inverse', '--text-inverse'
|
||||
);
|
||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||
|
||||
// A focused school that leaves the basket must not linger.
|
||||
@@ -67,7 +75,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
|
||||
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||||
const school = schools[series.schoolIndex];
|
||||
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
|
||||
const color = seriesColors[series.schoolIndex % seriesColors.length];
|
||||
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||||
|
||||
return {
|
||||
@@ -90,7 +98,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
datasets.push({
|
||||
label: built.englandDataset.label,
|
||||
data: built.englandDataset.data,
|
||||
borderColor: 'rgba(109, 104, 95, 0.9)',
|
||||
borderColor: cRef,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: built.englandDataset.borderDash,
|
||||
@@ -138,7 +146,9 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: isMobile ? 10 : 12,
|
||||
titleFont: {
|
||||
size: isMobile ? 12 : 14,
|
||||
@@ -182,7 +192,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
|
||||
...(isMobile && { maxTicksLimit: 5 }),
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
color: cGrid,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
color: var(--text-primary, #2c2420);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(44, 36, 32, 0.18), 0 2px 8px rgba(44, 36, 32, 0.08);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.18), 0 2px 8px rgba(var(--shadow-rgb), 0.08);
|
||||
border: 1px solid var(--border);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
@@ -62,13 +62,13 @@
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
|
||||
.collapseBtn:hover {
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schoolList {
|
||||
@@ -95,13 +95,13 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.schoolName {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -111,7 +111,7 @@
|
||||
.removeSchoolBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
.removeSchoolBtn:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.toastActions {
|
||||
@@ -129,13 +129,13 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-color, #e8ddd4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btnClearAll {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -145,13 +145,13 @@
|
||||
}
|
||||
|
||||
.btnClearAll:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.btnCompare {
|
||||
flex: 1;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
.btnCompare:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--accent-coral-dark, #c9614a);
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* Hidden on phones — the bottom tab bar's Compare badge already
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
.header h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
max-width: 60ch;
|
||||
@@ -43,7 +43,7 @@
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
@@ -53,8 +53,8 @@
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@@ -62,21 +62,21 @@
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||
@@ -87,23 +87,23 @@
|
||||
position: sticky;
|
||||
top: 65px;
|
||||
z-index: 10;
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.schoolChip {
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 3px solid var(--accent-coral, #e07256);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--brand);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
@@ -126,12 +126,12 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chipName:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
/* Full name on desktop, short name on the compact mobile pills. */
|
||||
@@ -142,7 +142,7 @@
|
||||
.chipMeta {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -185,22 +185,22 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.barCaptionCount {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chipRemove {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #6d685f);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -212,9 +212,9 @@
|
||||
|
||||
.footnote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--border-light, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
@@ -224,11 +224,11 @@ export function ComparisonView({
|
||||
const url = window.location.href;
|
||||
const count = selectedSchools.length;
|
||||
const shareData = {
|
||||
title: 'School comparison · SchoolCompare',
|
||||
title: 'School comparison · schoolcompare',
|
||||
text:
|
||||
count > 0
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||
: 'SchoolCompare',
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on schoolcompare`
|
||||
: 'schoolcompare',
|
||||
url,
|
||||
};
|
||||
if (
|
||||
|
||||
@@ -23,16 +23,16 @@
|
||||
}
|
||||
|
||||
.good {
|
||||
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--status-above-bg);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.bad {
|
||||
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-muted, #8a847a);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -61,7 +61,7 @@
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
|
||||
box-shadow: 0 0 0 1px rgba(var(--shadow-rgb), 0.08);
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
|
||||
@@ -5,54 +5,60 @@ import styles from './HomeView.module.css';
|
||||
interface EditorialSectionProps {
|
||||
totalSchools: number | null;
|
||||
localAuthorityCount: number;
|
||||
latestYearLabel: string | null;
|
||||
earliestYearLabel: string | null;
|
||||
}
|
||||
|
||||
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
|
||||
export function EditorialSection({
|
||||
totalSchools,
|
||||
localAuthorityCount,
|
||||
latestYearLabel,
|
||||
earliestYearLabel,
|
||||
}: EditorialSectionProps) {
|
||||
/*
|
||||
* The coverage line replaces a six-row "Coverage at a glance" table.
|
||||
*
|
||||
* That table put six numbers on the page to say one thing, and the headline
|
||||
* row was wrong: it read "24,000+" — its own hardcoded fallback — because
|
||||
* DataInfoResponse declared a `total_schools` field the API does not send
|
||||
* (it sends `unique_schools`). The fetch had succeeded; only that field was
|
||||
* undefined, so the fallback rendered and nothing failed. Every figure here
|
||||
* is now live, and any figure that isn't available is dropped rather than
|
||||
* replaced with a guess.
|
||||
*/
|
||||
const coverage = [
|
||||
totalSchools ? `${totalSchools.toLocaleString('en-GB')} schools` : null,
|
||||
localAuthorityCount > 0 ? `${localAuthorityCount} local authorities` : null,
|
||||
earliestYearLabel && latestYearLabel ? `${earliestYearLabel}–${latestYearLabel}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<section className={styles.editorial}>
|
||||
<div className={styles.editorialGrid}>
|
||||
// No class on the section: the page container owns the vertical rhythm
|
||||
// now, and this band carries its own ground on the card inside it.
|
||||
<section>
|
||||
<div className={styles.editorialCard}>
|
||||
<div className={styles.sectionHead}>
|
||||
<p className={styles.sectionKicker}>About school data</p>
|
||||
<h2 className={styles.sectionHeading}>
|
||||
Making England's school performance data actually readable
|
||||
</h2>
|
||||
</div>
|
||||
<div className={styles.editorialText}>
|
||||
<div className={styles.editorialKicker}>About school data</div>
|
||||
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
||||
<p>
|
||||
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||
publish Key Stage 2 SATs, GCSE attainment, inspection 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
|
||||
schoolcompare brings it all into one place. Every school page shows performance against the national
|
||||
average, explains what the numbers mean, and lets you compare schools side by side. Built for
|
||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||
full 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>
|
||||
{coverage.length > 0 && (
|
||||
<p className={styles.coverageLine}>{coverage.join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
background: var(--bg-card, white);
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 16px;
|
||||
min-height: 400px;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -22,14 +22,14 @@
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0 0 2rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
max-width: 500px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -38,8 +38,8 @@
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -47,9 +47,9 @@
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(224, 114, 86, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(var(--brand-rgb), 0.3);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
/*
|
||||
* FilterBar — one component, two shapes.
|
||||
*
|
||||
* hero a single white pill on the Sand hero panel, carrying the one
|
||||
* coral action the page exists for. No card of its own: the panel
|
||||
* behind it belongs to the page, so the pill reads as the object.
|
||||
* compact the filter rail above the results — a white card of quiet
|
||||
* controls, brand for what is active, sage for what is selected.
|
||||
*
|
||||
* Colour comes from the token layer only; the disclosure arrows are drawn
|
||||
* from currentColor rather than a background image so they follow the theme.
|
||||
*/
|
||||
|
||||
/* ── Shell ────────────────────────────────────────────────────────── */
|
||||
|
||||
.filterBar {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
transition: opacity 0.2s ease;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.filterBar.isLoading {
|
||||
@@ -14,112 +29,16 @@
|
||||
}
|
||||
|
||||
.heroMode {
|
||||
padding: 2.5rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 3rem auto;
|
||||
box-shadow: 0 8px 24px rgba(26, 22, 18, 0.08);
|
||||
border-width: 2px;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.heroMode .omniInput {
|
||||
font-size: 1.25rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
font-size: 1.25rem;
|
||||
padding: 1.25rem 2.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
margin: 0.875rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.searchHint strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.searchHint {
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
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-dark, #b04a2e);
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.nearMeBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
/* ── Search ───────────────────────────────────────────────────────── */
|
||||
|
||||
.searchSection {
|
||||
margin-bottom: 0;
|
||||
@@ -127,43 +46,90 @@
|
||||
|
||||
.omniBoxContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* The hero pill: hairline, soft corner, everything else sits inside it. */
|
||||
.heroMode .omniBoxContainer {
|
||||
gap: 0.375rem;
|
||||
padding: 0.3125rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.heroMode .omniBoxContainer:focus-within {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
.omniIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding-left: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.omniInput {
|
||||
flex: 1;
|
||||
padding: 0.875rem 1.25rem;
|
||||
font-size: 1.05rem;
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
min-width: 0;
|
||||
padding: 0.6875rem 0.875rem;
|
||||
font-family: var(--font-ui);
|
||||
/* 1rem, not smaller: iOS Safari zooms the viewport on any control under
|
||||
16px, which throws the user out of the hero on first tap. */
|
||||
font-size: var(--step-0);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-card, white);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.omniInput:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.omniInput::placeholder {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.omniInput:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
/* Inside the pill the input carries no chrome — the pill holds the border
|
||||
and the focus ring, so the two never double up. */
|
||||
.heroMode .omniInput {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.heroMode .omniInput:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1.05rem;
|
||||
border-radius: 8px;
|
||||
min-width: 120px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.6875rem 1.25rem;
|
||||
font-size: var(--step--1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
padding: 0.75rem 1.375rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -171,103 +137,86 @@
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
/* ── Hero supporting copy ─────────────────────────────────────────── */
|
||||
|
||||
.searchHint {
|
||||
margin: 0.75rem 0 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
padding: 0.625rem 2.25rem 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-card, white);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.875rem center;
|
||||
background-size: 10px 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
color: var(--text-primary, #1a1612);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
.searchHint strong {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.omniBoxContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.radiusWrapper {
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
white-space: nowrap;
|
||||
/* Quiet text link, not a second button: the coral submit is the decisive
|
||||
action and nothing beside it may compete for the same weight. */
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
/* ── Controls row (radius + phase + advanced toggle) ─── */
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.geoError {
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.45;
|
||||
color: var(--brand-strong);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Controls row (radius + phase + advanced toggle) ──────────────── */
|
||||
|
||||
.controlsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -275,7 +224,7 @@
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.875rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
@@ -289,76 +238,157 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Pill-style inline filter controls (radius + phase) */
|
||||
.controlSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding: 0.4rem 2rem 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-card, white);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.65rem center;
|
||||
background-size: 10px 6px;
|
||||
color: var(--text-primary, #1a1612);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
.radiusLabel {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controlSelect:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
/* ── Selects ──────────────────────────────────────────────────────── */
|
||||
|
||||
.selectShell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selectShellWide {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.selectShell select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Drawn, not imaged: a background-image arrow has to hardcode its own
|
||||
colour, and would stay light-mode grey on the dark ground. */
|
||||
.selectChevron {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: -2px;
|
||||
pointer-events: none;
|
||||
color: var(--text-muted);
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid currentColor;
|
||||
}
|
||||
|
||||
.filterSelect,
|
||||
.controlSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color var(--transition), background-color var(--transition),
|
||||
box-shadow var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
padding: 0.5625rem 2rem 0.5625rem 0.75rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
padding: 0.4375rem 1.875rem 0.4375rem 0.75rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterSelect:hover:not(:disabled),
|
||||
.controlSelect:hover:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.filterSelect:focus,
|
||||
.controlSelect:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ─────────────────────────── */
|
||||
/* Selected — i.e. this control is narrowing the list. Sage tint, brand
|
||||
line: the same pairing the results use for "on". */
|
||||
.selectActive {
|
||||
background-color: rgba(var(--sage-rgb), 0.38);
|
||||
border-color: var(--brand);
|
||||
color: var(--brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selectActive:hover:not(:disabled) {
|
||||
background-color: rgba(var(--sage-rgb), 0.5);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ──────────────────────────────────────── */
|
||||
|
||||
.advancedToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
background: none;
|
||||
border: 1.5px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
transition: border-color var(--transition), background-color var(--transition),
|
||||
color var(--transition);
|
||||
}
|
||||
|
||||
.advancedToggle:hover {
|
||||
border-color: var(--text-muted, #8a847a);
|
||||
background-color: var(--bg-secondary, #f8f4ef);
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-color: var(--border-strong);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
/* Applied filters read as a brand chip, so it's obvious at a glance that
|
||||
the list below 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);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand);
|
||||
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);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.4375rem 0.875rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
@@ -377,3 +407,94 @@
|
||||
.chevronUp {
|
||||
border-bottom: 4.5px solid currentColor;
|
||||
}
|
||||
|
||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
/* The hero pill stays a pill at every width — stacking it would lose the
|
||||
one shape the page is built around. Only the results bar stacks. */
|
||||
.filterBar:not(.heroMode) .omniBoxContainer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filterBar:not(.heroMode) .searchButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selectShellWide {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.geoError {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Narrow phones: the button drops below the input instead of sharing the row.
|
||||
*
|
||||
* "Search schools" is a fixed 134px with `white-space: nowrap`, so on a 390px
|
||||
* screen it took 48% of the row and left the input 102px of text space for a
|
||||
* 194px placeholder — the field showed "School name or" and stopped. At 320px
|
||||
* the input was down to 32px, which is too narrow to read what you are typing.
|
||||
*
|
||||
* Wrapping rather than restructuring: the pill stays one element, so it keeps
|
||||
* its border, shadow and :focus-within ring, and the button gets a full-width
|
||||
* tap target on the way. Giving the button a 100% flex-basis is what forces
|
||||
* the wrap; the input keeps `flex: 1` and so takes the whole first row.
|
||||
*/
|
||||
@media (max-width: 480px) {
|
||||
.heroMode .omniBoxContainer {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.heroMode .searchButton {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
/* Below this the pin costs more room than it earns. */
|
||||
.heroMode .omniIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heroMode .omniInput {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { isValidPostcode } from "@/lib/utils";
|
||||
import { track } from "@/lib/analytics";
|
||||
@@ -18,6 +19,28 @@ interface FilterBarProps {
|
||||
geoError?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a native <select> so the disclosure arrow can be drawn in CSS from
|
||||
* currentColor rather than baked into a background image — a hardcoded arrow
|
||||
* colour is the one thing in a select that can't follow the theme.
|
||||
*/
|
||||
function SelectShell({
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`${styles.selectShell}${wide ? ` ${styles.selectShellWide}` : ""}`}
|
||||
>
|
||||
{children}
|
||||
<span className={styles.selectChevron} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
filters,
|
||||
isHero,
|
||||
@@ -172,12 +195,33 @@ export function FilterBar({
|
||||
const isSecondaryMode =
|
||||
currentPhase === "secondary" || genderOptions.length > 0;
|
||||
|
||||
// A select that is narrowing the results carries a sage tint; the class is
|
||||
// only ever additive, so the control's behaviour is untouched.
|
||||
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
||||
>
|
||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
||||
<div className={styles.omniBoxContainer}>
|
||||
{isHero && (
|
||||
<span className={styles.omniIcon} aria-hidden="true">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z" />
|
||||
<circle cx="12" cy="10" r="2.6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
@@ -188,10 +232,10 @@ export function FilterBar({
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${styles.searchButton}`}
|
||||
className={`btn btn-action ${styles.searchButton}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{isHero && (
|
||||
@@ -248,34 +292,38 @@ export function FilterBar({
|
||||
{currentPostcode && (
|
||||
<div className={styles.radiusControl}>
|
||||
<label className={styles.radiusLabel}>Within:</label>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="0.5">0.5 miles</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
</select>
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="0.5">0.5 miles</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phaseOptions.length > 0 && (
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -307,87 +355,97 @@ export function FilterBar({
|
||||
|
||||
{filtersOpen && (
|
||||
<div className={styles.filters}>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("local_authority", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("local_authority", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("school_type", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("school_type", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentType)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{isSecondaryMode && (
|
||||
<>
|
||||
{genderOptions.length > 0 && (
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("gender", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("gender", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentGender)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("has_sixth_form", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form</option>
|
||||
<option value="no">Without sixth form</option>
|
||||
</select>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("admissions_policy", e.target.value)
|
||||
handleFilterChange("has_sixth_form", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form</option>
|
||||
<option value="no">Without sixth form</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("admissions_policy", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/*
|
||||
* The footer band.
|
||||
*
|
||||
* --surface-sunken is deep teal in BOTH themes on purpose, so nothing in here
|
||||
* may read a --text-* or --bg-* token: those invert with the theme and would
|
||||
* put dark ink on the teal (or teal on teal) in one of the two. Every
|
||||
* foreground below comes from the --on-sunken-* family, which is pinned to the
|
||||
* band rather than to the page.
|
||||
*/
|
||||
|
||||
.footer {
|
||||
background: var(--accent-navy, #2c3e50);
|
||||
color: var(--bg-secondary, #f3ede4);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--on-sunken);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@@ -12,7 +22,7 @@
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@@ -20,31 +30,97 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
/* ─── Brand lockup ──────────────────────────────────────────────── */
|
||||
|
||||
.lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--bg-primary, #faf7f2);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
.lockupMark {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
/* Height drives; the artwork is taller than it is wide. */
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.lockupMark img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Same wordmark as the header, inverted: "school" in the band's foreground,
|
||||
"compare" in sage — the one accent that reads cleanly on deep teal. */
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.wordmarkAccent {
|
||||
color: var(--sage);
|
||||
}
|
||||
|
||||
/* Key messaging, so this is the one place in the footer that takes Manrope
|
||||
for a full sentence. */
|
||||
.tagline {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
margin: -0.5rem 0 0;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.6;
|
||||
color: rgba(250, 247, 242, 0.7);
|
||||
color: var(--on-sunken-muted);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
/*
|
||||
* Quieter than .description, but by size only — not by contrast.
|
||||
*
|
||||
* --on-sunken-faint measured 4.71:1 here, which clears AA by 0.21. That is a
|
||||
* fine margin for decorative text and the wrong one for a disclaimer, whose
|
||||
* whole job is to be legible to someone checking whether this is a government
|
||||
* site. --on-sunken-muted is 5.66:1 on the same band.
|
||||
*/
|
||||
.independence {
|
||||
margin: 0;
|
||||
font-size: var(--step--2);
|
||||
line-height: 1.6;
|
||||
color: var(--on-sunken-muted);
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-gold, #c9a227);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
/* --sage flips with the theme; this band does not (it is teal in both), so
|
||||
the pairing only held in one of them — the dark sage measured 4.08:1 here
|
||||
against a 4.5 floor, on every page. The --on-sunken-* family exists
|
||||
precisely because this surface is theme-invariant. */
|
||||
color: var(--on-sunken-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.links {
|
||||
@@ -57,43 +133,64 @@
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.7);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--accent-gold, #c9a227);
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.linkDisabled {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Bottom rule ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* The divider is drawn as a pseudo-element at low opacity rather than as an
|
||||
* rgba() border: there is no channel token for --on-sunken, and every literal
|
||||
* that would work here (rgba(255,255,255,…)) is exactly the kind of hardcoded
|
||||
* colour the token layer exists to remove.
|
||||
*/
|
||||
.bottom {
|
||||
position: relative;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(250, 247, 242, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bottom::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--on-sunken);
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.copyright,
|
||||
.disclaimer {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(250, 247, 242, 0.6);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-muted);
|
||||
}
|
||||
|
||||
.disclaimer .link {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--on-sunken-link);
|
||||
}
|
||||
|
||||
.disclaimer .link:hover {
|
||||
color: var(--accent-gold, #c9a227);
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Footer Component
|
||||
* Site footer with links and info
|
||||
*
|
||||
* The footer is the guideline's "brand in action" band: a deep teal anchor
|
||||
* that stays teal in both themes. Everything inside it therefore reads from
|
||||
* the --on-sunken-* family rather than the page's --text-* tokens, which
|
||||
* invert and would go dark against the teal in one theme or the other.
|
||||
*/
|
||||
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export function Footer() {
|
||||
@@ -13,10 +18,31 @@ export function Footer() {
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.title}>SchoolCompare</h3>
|
||||
{/*
|
||||
On the sunken band the mark inverts: the pin takes the band's
|
||||
foreground and the leaf is knocked out in the band itself. The
|
||||
header's defaults (brand pin, card leaf) would disappear here.
|
||||
*/}
|
||||
<h3 className={styles.lockup}>
|
||||
<span className={styles.lockupMark}>
|
||||
<LogoMark variant="onDark" size={34} />
|
||||
</span>
|
||||
<span className={styles.wordmark}>
|
||||
school<span className={styles.wordmarkAccent}>compare</span>
|
||||
</span>
|
||||
</h3>
|
||||
<p className={styles.tagline}>Find the right school. For their future.</p>
|
||||
<p className={styles.description}>
|
||||
Compare primary and secondary schools across England.
|
||||
</p>
|
||||
{/*
|
||||
Says once, plainly, what the landing page's value props only
|
||||
imply: we publish official data, we are not an official body.
|
||||
Cheap to state and expensive to be wrong about.
|
||||
*/}
|
||||
<p className={styles.independence}>
|
||||
An independent site. Not affiliated with the Department for Education or Ofsted.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:contact@schoolcompare.co.uk"
|
||||
className={styles.link}
|
||||
@@ -31,7 +57,9 @@ export function Footer() {
|
||||
<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>
|
||||
{/* "Compare", not "shortlist" — the nav, this link and the
|
||||
landing page all name the same feature the same way. */}
|
||||
<li><a href="/compare" className={styles.link}>Compare schools</a></li>
|
||||
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -69,7 +97,7 @@ export function Footer() {
|
||||
|
||||
<div className={styles.bottom}>
|
||||
<p className={styles.copyright}>
|
||||
© {currentYear} SchoolCompare.co.uk
|
||||
© {currentYear} schoolcompare.co.uk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+309
-118
@@ -12,6 +12,7 @@ import { SchoolRow } from './SchoolRow';
|
||||
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { HeroIllustration } from './Illustration';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
@@ -39,13 +40,22 @@ function daysUntil(month: number, day: number): number {
|
||||
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatCountdownDate(month: number, day: number): string {
|
||||
function nextOccurrence(month: number, day: number): Date {
|
||||
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' });
|
||||
const target = new Date(y, month - 1, day);
|
||||
return target < today ? new Date(y + 1, month - 1, day) : target;
|
||||
}
|
||||
|
||||
function formatCountdownDate(month: number, day: number): string {
|
||||
return nextOccurrence(month, day)
|
||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatShortDate(month: number, day: number): string {
|
||||
return nextOccurrence(month, day)
|
||||
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
interface CountdownChipData {
|
||||
@@ -56,6 +66,16 @@ interface CountdownChipData {
|
||||
day: number;
|
||||
}
|
||||
|
||||
interface AdmissionsMilestone {
|
||||
chip: CountdownChipData;
|
||||
/** Whole days from today. Always present — computed during render. */
|
||||
days: number;
|
||||
/** "Sat, 31 October 2026" — for the headline bar. */
|
||||
dateLabel: string;
|
||||
/** "31 Oct 2026" — for the supporting line, where four dates share a row. */
|
||||
shortDate: string;
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -63,6 +83,116 @@ const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||
];
|
||||
|
||||
/* ── Value-prop icons ──────────────────────────────────────────────────────
|
||||
Rounded 2px line icons on a 24px grid, inheriting the chip's colour. Kept
|
||||
local because they exist for this one row and nothing else. */
|
||||
|
||||
const ICON_PROPS = {
|
||||
viewBox: '0 0 24 24',
|
||||
width: 21,
|
||||
height: 21,
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': true,
|
||||
} as const;
|
||||
|
||||
function ShieldCheckIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 3 4 6v6c0 4.5 3.2 8.2 8 9 4.8-.8 8-4.5 8-9V6l-8-3Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BarsIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.6 12 20 12 20Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueProp {
|
||||
icon: React.ReactNode;
|
||||
tintClass: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
||||
* favourites). That feature does not exist in this product, so it is
|
||||
* deliberately replaced by the admissions-deadline prop below — which is real,
|
||||
* and is backed by the next-deadline bar further down this page.
|
||||
*
|
||||
* Every claim here must name something the product actually does. Two of the
|
||||
* four previously did not: "up to three schools" contradicted MAX_SCHOOLS = 5
|
||||
* in context/ComparisonProvider.tsx (and the card further down the page, which
|
||||
* correctly said five), and "class sizes" described data the codebase has never
|
||||
* held — grep for it and this line was the only hit. Both are corrected below
|
||||
* against the real fields, which live in components/school/InclusionSection.tsx.
|
||||
*/
|
||||
const VALUE_PROPS: ValueProp[] = [
|
||||
{
|
||||
icon: <ShieldCheckIcon />,
|
||||
tintClass: styles.propIconTrust,
|
||||
/*
|
||||
* "Built on official data", never "Official".
|
||||
*
|
||||
* The previous title was "Official & trusted", whose grammatical subject
|
||||
* is this site — it reads as a claim that schoolcompare is itself an
|
||||
* official service. It is not: it is an independent site that republishes
|
||||
* official figures. The distinction is the difference between describing
|
||||
* the data and describing ourselves, and only the first is true.
|
||||
*
|
||||
* Everywhere else the word appears ("official DfE figures", "the official
|
||||
* figure isn't in") it already qualifies the data, which is correct and
|
||||
* should stay.
|
||||
*/
|
||||
title: 'Built on official data',
|
||||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||
},
|
||||
{
|
||||
icon: <BarsIcon />,
|
||||
tintClass: styles.propIconCompare,
|
||||
title: 'Easy to compare',
|
||||
body: 'Up to five schools side by side, on the measures that matter.',
|
||||
},
|
||||
{
|
||||
icon: <HeartIcon />,
|
||||
tintClass: styles.propIconContext,
|
||||
title: 'Beyond the numbers',
|
||||
body: 'SEN support, pupil premium and attendance — not just results.',
|
||||
},
|
||||
{
|
||||
icon: <CalendarIcon />,
|
||||
tintClass: styles.propIconDeadline,
|
||||
title: 'Never miss a deadline',
|
||||
body: 'Application and offer dates for both primary and secondary.',
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -83,9 +213,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
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 }))
|
||||
);
|
||||
/*
|
||||
* Computed during render, on the server as well as the client, so the
|
||||
* deadline bar is in the first HTML rather than appearing on hydrate.
|
||||
*
|
||||
* The previous version deferred this to an effect to dodge a hydration
|
||||
* mismatch, which meant the section had to reserve its own height — and the
|
||||
* reservation was a single guessed number for a block whose supporting line
|
||||
* wraps to a different height at every width. Measured, it was short at
|
||||
* every breakpoint, shifting the page by up to 108px on a phone.
|
||||
*
|
||||
* The mismatch it was dodging is real but tiny: a server rendering at
|
||||
* 23:59:59 and a client hydrating at 00:00:01 disagree by one day. That is
|
||||
* confined to two text nodes, which carry suppressHydrationWarning below.
|
||||
*/
|
||||
const [milestones] = useState<AdmissionsMilestone[]>(() => {
|
||||
const measured = ADMISSIONS_CHIPS.map(chip => ({
|
||||
chip,
|
||||
days: daysUntil(chip.month, chip.day),
|
||||
dateLabel: formatCountdownDate(chip.month, chip.day),
|
||||
shortDate: formatShortDate(chip.month, chip.day),
|
||||
}));
|
||||
measured.sort((a, b) => a.days - b.days);
|
||||
return measured;
|
||||
});
|
||||
|
||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||
const isLocationSearch = !!searchParams.get('postcode');
|
||||
@@ -148,13 +299,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
.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 });
|
||||
@@ -243,6 +387,28 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
});
|
||||
}, [initialSchools.total, isSearchActive, searchParams]);
|
||||
|
||||
/*
|
||||
* The coverage figure, or nothing at all.
|
||||
*
|
||||
* This used to be the string "24,000+", hardcoded in three places while the
|
||||
* database held 27,230 — and the fact box that was meant to show the live
|
||||
* number silently rendered its fallback, because DataInfoResponse declared a
|
||||
* `total_schools` field the API has never sent. Now there is one source: if
|
||||
* the real count is unavailable the sentence simply omits it rather than
|
||||
* inventing a floor.
|
||||
*/
|
||||
const coverageLabel = totalSchools ? totalSchools.toLocaleString('en-GB') : null;
|
||||
|
||||
/*
|
||||
* The headline milestone is the next thing a parent can actually miss — an
|
||||
* application deadline. Offer days are dates you receive something on, so
|
||||
* they belong in the supporting line, not in the page's largest numeral.
|
||||
* Four equally-sized cards counting down to things 245 days away was the
|
||||
* loudest element on the page and asked nothing of anyone.
|
||||
*/
|
||||
const nextDeadline = milestones.find(m => m.chip.type === 'deadline') ?? null;
|
||||
const laterMilestones = milestones.filter(m => m !== nextDeadline);
|
||||
|
||||
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||||
// split which surface drives compare adds.
|
||||
const addSchoolFromSearch = useCallback((school: School) => {
|
||||
@@ -255,113 +421,137 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
}, [addSchool, selectedSchools.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.homeView}>
|
||||
{/* Combined Hero + Search and Filters */}
|
||||
{!isSearchActive && (
|
||||
<div className={styles.heroSection}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
{/* Full copy for larger screens; a compact merged line (coverage +
|
||||
freshness, standing in for the hidden eyebrow too) on phones,
|
||||
where every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
<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.
|
||||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||
CSS); the search arrangement is a filter bar directly above its results
|
||||
and wants none of it. */
|
||||
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
|
||||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||
the brand landscape bleeding to the panel edge on the right. The
|
||||
search lives inside the panel here and above the results elsewhere,
|
||||
which is why FilterBar is rendered in two places rather than moved.
|
||||
The hero and the four value props are one group, so they sit closer
|
||||
together than the page's section gap. */}
|
||||
{!isSearchActive ? (
|
||||
<div className={styles.heroGroup}>
|
||||
<section className={styles.heroPanel}>
|
||||
<div className={styles.heroContent}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<span className={styles.heroDescriptionCompact}>
|
||||
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<h1 className={styles.heroTitle}>
|
||||
Find the school where your child will{' '}
|
||||
<em className={styles.heroEmph}>flourish.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
Compare every school in England with clarity and confidence.
|
||||
{/* The coverage sentence is the detail a first-time visitor needs
|
||||
but a returning one does not, so it drops away on phones where
|
||||
every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||||
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>} — side by
|
||||
side, in one place.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={!isSearchActive}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/*
|
||||
Rendered after the content on purpose. On desktop it is absolutely
|
||||
positioned behind the panel, so order does not matter; below the
|
||||
one-column breakpoint it returns to normal flow, where DOM order is
|
||||
what puts the band under the search rather than above the headline.
|
||||
*/}
|
||||
<div className={styles.heroArt}>
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why this site, in four lines. These titles are labels on a list,
|
||||
not section headings — as <h2> they outranked the page's real
|
||||
headings in the document outline and gave a screen-reader user
|
||||
four false landmarks before any content. */}
|
||||
<ul className={styles.valueProps}>
|
||||
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
||||
<li key={title} className={styles.valueProp}>
|
||||
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
||||
<div>
|
||||
<p className={styles.propTitle}>{title}</p>
|
||||
<p className={styles.propBody}>{body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={false}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* Next admissions deadline — one bar, not four cards. */}
|
||||
{!isSearchActive && (
|
||||
<section className={styles.admissions} aria-label="Admissions deadlines">
|
||||
{nextDeadline && (
|
||||
<>
|
||||
<a
|
||||
href="/admissions"
|
||||
className={
|
||||
nextDeadline.days <= 14
|
||||
? `${styles.nextDeadline} ${styles.nextDeadlineUrgent}`
|
||||
: styles.nextDeadline
|
||||
}
|
||||
>
|
||||
<span className={styles.nextDeadlineBody}>
|
||||
<span className={styles.nextDeadlineKicker}>
|
||||
Next admissions deadline · {nextDeadline.chip.track.split(' · ')[0]}
|
||||
</span>
|
||||
<span className={styles.nextDeadlineTitle}>{nextDeadline.chip.milestone}</span>
|
||||
<span className={styles.nextDeadlineDate} suppressHydrationWarning>
|
||||
{nextDeadline.dateLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.nextDeadlineCount}>
|
||||
<span className={styles.nextDeadlineDays} suppressHydrationWarning>
|
||||
{nextDeadline.days === 0 ? 'Today' : nextDeadline.days}
|
||||
</span>
|
||||
{nextDeadline.days !== 0 && (
|
||||
<span className={styles.nextDeadlineUnit}>days left</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
<p className={styles.laterDates} suppressHydrationWarning>
|
||||
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
|
||||
{' '}
|
||||
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||||
{!isSearchActive && howItWorks}
|
||||
{!isSearchActive && editorial}
|
||||
|
||||
{/* Results Section */}
|
||||
{/* Results Section. Skipped entirely on the landing page when there is
|
||||
nothing to list — an empty <section> is still a flex child, so it was
|
||||
contributing a full section gap of blank space above the footer. */}
|
||||
{(isSearchActive || initialSchools.schools.length > 0) && (
|
||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||
<div className={styles.sectionHeader}>
|
||||
@@ -547,6 +737,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -573,10 +764,10 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
||||
rwmDelta == null
|
||||
? {}
|
||||
: rwmDelta >= 2
|
||||
? { fontSize: '0.7rem', color: 'var(--accent-teal, #2d7d7d)', fontWeight: 600 }
|
||||
? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
|
||||
: rwmDelta <= -2
|
||||
? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 }
|
||||
: { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' };
|
||||
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
|
||||
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
|
||||
|
||||
return (
|
||||
<div className={styles.compactItem}>
|
||||
@@ -601,13 +792,13 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
|
||||
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: '#2f7a2f' } :
|
||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: 'var(--accent-gold-text, #7a6800)' } :
|
||||
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.cssClass === 'ofsted1' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
|
||||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
|
||||
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
|
||||
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
|
||||
}}
|
||||
>
|
||||
{ofstedBadge.label}
|
||||
|
||||
@@ -13,6 +13,16 @@ export function HowItWorksSection() {
|
||||
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||
];
|
||||
/*
|
||||
* The columns are deliberately unnamed.
|
||||
*
|
||||
* They previously carried two real school names — "Our Lady Queen of Heaven"
|
||||
* and "St Mary's Catholic Primary" — beside invented results and invented
|
||||
* Ofsted grades. On a site whose entire proposition is official DfE and
|
||||
* Ofsted data, publishing made-up figures against identifiable schools is
|
||||
* the one thing it cannot do, and no illustrative intent survives being
|
||||
* screenshotted. Generic labels make the layout point just as well.
|
||||
*/
|
||||
const compareRows = [
|
||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||
@@ -29,9 +39,15 @@ export function HowItWorksSection() {
|
||||
|
||||
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>
|
||||
{/* One header pattern across every band on this page: kicker, heading,
|
||||
optional aside. The page previously ran four different treatments
|
||||
(two uppercase micro-labels — one left, one centred — a large heading
|
||||
with right-aligned grey text, and a kicker-plus-heading), which is
|
||||
most of why a designed page read as a stack of unrelated strips. */}
|
||||
<div className={styles.sectionHead}>
|
||||
<p className={styles.sectionKicker}>Every school page</p>
|
||||
<h2 className={styles.sectionHeading}>What you'll see on every school</h2>
|
||||
<p className={styles.sectionAside}>Primary or secondary — the page adapts to the phase</p>
|
||||
</div>
|
||||
<div className={styles.hiwGrid}>
|
||||
{/* Card 1 — Performance */}
|
||||
@@ -117,8 +133,8 @@ export function HowItWorksSection() {
|
||||
<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 className={styles.compareHeadCell}>School A</div>
|
||||
<div className={styles.compareHeadCell}>School B</div>
|
||||
</div>
|
||||
{compareRows.map(({ label, a, b, aHi }) => (
|
||||
<div key={label} className={styles.compareRow}>
|
||||
@@ -127,13 +143,16 @@ export function HowItWorksSection() {
|
||||
<span className={styles.compareRowVal}>{b}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
||||
<div className={styles.compareFoot}>+ compare 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 className={styles.hiwTitle}>Side by side</div>
|
||||
{/* One verb for one feature. The site previously called this
|
||||
"compare" in the nav, "shortlist" in the footer and "pin" here,
|
||||
which reads as three separate things it does not have. */}
|
||||
<p className={styles.hiwDesc}>Compare up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* The landing hero artwork.
|
||||
*
|
||||
* This is the supplied illustration, not a drawing of one — see
|
||||
* assets/hero-source.png and scripts/build-hero-images.js, which produces
|
||||
* everything under public/brand/hero-*. Regenerate rather than hand-editing.
|
||||
*
|
||||
* Server-safe by construction: no 'use client', no hooks, no handlers, so it
|
||||
* renders in the RSC pass and never reaches the client bundle.
|
||||
*
|
||||
* TWO CROPS, ONE <picture>
|
||||
* ------------------------
|
||||
* The slot is two different shapes. On desktop the artwork sits behind the
|
||||
* whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream
|
||||
* area the illustration reserves on its left. Below the one-column breakpoint
|
||||
* it becomes a band between 2.6:1 and 4.9:1, under the search rather than
|
||||
* behind it.
|
||||
*
|
||||
* A single file under `object-fit: cover` centre-crops, and at the band's
|
||||
* extreme that slices a strip through the middle of the scene and loses the
|
||||
* schoolhouse — exactly how the previous SVG hero failed on phones. So the
|
||||
* <source media> switches crop, not just resolution: `band` is pre-cropped
|
||||
* around the school and is already near 2.6:1, so the narrow band barely
|
||||
* crops it further.
|
||||
*
|
||||
* `object-position` is set in CSS per breakpoint and is load-bearing — see
|
||||
* .heroArt in HomeView.module.css.
|
||||
*
|
||||
* The artwork is decorative: the proposition beside it carries the meaning, so
|
||||
* alt is empty and it is hidden from assistive tech rather than described.
|
||||
*/
|
||||
|
||||
/*
|
||||
* The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of
|
||||
* padding (1rem under 768px), so the artwork's box is that minus the padding —
|
||||
* describing it as 100vw over-requests by ~48px worth of candidate at every
|
||||
* width, which on a slow connection is a larger file than needed for the LCP
|
||||
* element.
|
||||
*/
|
||||
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
|
||||
|
||||
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
|
||||
const WIDE_W = 2000;
|
||||
const WIDE_H = 1125;
|
||||
|
||||
export function HeroIllustration() {
|
||||
return (
|
||||
<picture>
|
||||
{/* Band crop first: <source> is first-match-wins, so the narrow-viewport
|
||||
rules have to precede the unconstrained desktop ones. */}
|
||||
<source
|
||||
media="(max-width: 860px)"
|
||||
type="image/avif"
|
||||
srcSet="/brand/hero-band-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
<source
|
||||
media="(max-width: 860px)"
|
||||
type="image/webp"
|
||||
srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
{/*
|
||||
The band's JPEG, and the reason it exists.
|
||||
|
||||
<img src> cannot vary by viewport, so it is always the wide crop. A
|
||||
browser that takes neither AVIF nor WebP would therefore fall through
|
||||
to the desktop frame on a phone and lose the schoolhouse — the exact
|
||||
failure this whole component is arranged to prevent, surviving in the
|
||||
one path nobody looks at. No `type` here, so it matches anywhere the
|
||||
media query does, and it sits after the modern formats so they still
|
||||
win where supported.
|
||||
*/}
|
||||
<source media="(max-width: 860px)" srcSet="/brand/hero-band-900.jpg" />
|
||||
<source
|
||||
type="image/avif"
|
||||
srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
<source
|
||||
type="image/webp"
|
||||
srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
{/*
|
||||
The hero is the largest thing above the fold, so it is almost certainly
|
||||
the LCP element: fetchPriority high, and never lazy. eslint's
|
||||
no-img-element wants next/image, which cannot art-direct between two
|
||||
different crops — that is the whole point of the <picture> above.
|
||||
*/}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/brand/hero-wide-1400.jpg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={WIDE_W}
|
||||
height={WIDE_H}
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
/* font-size:0 hides the button's own "?" text node; the ::before glyph
|
||||
below carries the visible circled "?" at its own explicit size. */
|
||||
font-size: 0;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
@@ -34,17 +34,17 @@
|
||||
.icon:hover,
|
||||
.icon[aria-expanded='true'],
|
||||
.icon:focus-visible {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
z-index: 9999;
|
||||
width: max-content;
|
||||
max-width: min(260px, calc(100vw - 24px));
|
||||
background: var(--bg-primary, #faf7f2);
|
||||
border: 1px solid var(--border-color, #e8ddd4);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
|
||||
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.15);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -52,26 +52,26 @@
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--bg-primary, #faf7f2);
|
||||
stroke: var(--border-color, #e8ddd4);
|
||||
fill: var(--bg-primary);
|
||||
stroke: var(--border);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #2c2420);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plain {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5a4a44);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a7a72);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* LeafletCutoffMapInner
|
||||
* The cut-off distances drawn around the school.
|
||||
*
|
||||
* L.circle takes a radius in metres and projects it properly, which is exactly
|
||||
* what these rings are: a straight-line distance from the school. (L.circleMarker
|
||||
* would take pixels and would silently stop meaning anything as the user zoomed.)
|
||||
*
|
||||
* The latest year is filled and labelled; earlier years are hairlines behind it,
|
||||
* so a tightening cut-off reads as a set of shrinking circles without needing a
|
||||
* legend to decode it. The map auto-fits the widest ring, so the whole history
|
||||
* is in frame whatever the distances are.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
export interface CutoffRing {
|
||||
year: number;
|
||||
radiusM: number;
|
||||
latest: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lat: number;
|
||||
lng: number;
|
||||
rings: CutoffRing[];
|
||||
/** The parent's geocoded postcode, once they have entered one. */
|
||||
home: { lat: number; lng: number } | null;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
const SCHOOL_PIN = `
|
||||
<div style="position:relative;width:22px;height:22px">
|
||||
<span style="position:absolute;left:50%;top:50%;width:26px;height:26px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
|
||||
<span style="position:absolute;left:50%;top:50%;width:14px;height:14px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand);border:3px solid var(--bg-card)"></span>
|
||||
</div>`;
|
||||
|
||||
// A square rotated 45° — a different SHAPE from the school's circle, not just a
|
||||
// different colour, so the two are still distinguishable to anyone who cannot
|
||||
// separate the hues.
|
||||
const HOME_PIN = `
|
||||
<div style="width:18px;height:18px;display:grid;place-items:center">
|
||||
<span style="width:12px;height:12px;background:var(--status-above);border:2.5px solid var(--bg-card);transform:rotate(45deg);border-radius:2px"></span>
|
||||
</div>`;
|
||||
|
||||
function cssVar(name: string, fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
export default function LeafletCutoffMapInner({ lat, lng, rings, home, interactive }: Props) {
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const layersRef = useRef<L.Layer[]>([]);
|
||||
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!elRef.current || mapRef.current) return;
|
||||
const map = L.map(elRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
dragging: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
touchZoom: false,
|
||||
}).setView([lat, lng], 14);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
setTimeout(() => map.invalidateSize(), 60);
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
layersRef.current = [];
|
||||
zoomCtrlRef.current = null;
|
||||
};
|
||||
}, [lat, lng]);
|
||||
|
||||
// Redraw rings, pins and the viewport whenever the data changes.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
layersRef.current.forEach((l) => l.remove());
|
||||
layersRef.current = [];
|
||||
|
||||
const brand = cssVar('--brand', '#0F766E');
|
||||
const above = cssVar('--status-above', '#2F855A');
|
||||
|
||||
// Widest first, so the small recent ring is not buried under the old ones.
|
||||
const ordered = [...rings].sort((a, b) => b.radiusM - a.radiusM);
|
||||
for (const r of ordered) {
|
||||
const circle = L.circle([lat, lng], {
|
||||
radius: r.radiusM,
|
||||
color: brand,
|
||||
weight: r.latest ? 3 : 1.5,
|
||||
opacity: r.latest ? 1 : 0.45,
|
||||
fill: r.latest,
|
||||
fillColor: brand,
|
||||
fillOpacity: r.latest ? 0.12 : 0,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(circle);
|
||||
}
|
||||
|
||||
const school = L.marker([lat, lng], {
|
||||
icon: L.divIcon({ className: '', iconSize: [22, 22], iconAnchor: [11, 11], html: SCHOOL_PIN }),
|
||||
keyboard: false,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(school);
|
||||
|
||||
if (home) {
|
||||
const pin = L.marker([home.lat, home.lng], {
|
||||
icon: L.divIcon({ className: '', iconSize: [18, 18], iconAnchor: [9, 9], html: HOME_PIN }),
|
||||
keyboard: false,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(pin);
|
||||
|
||||
const line = L.polyline([[lat, lng], [home.lat, home.lng]], {
|
||||
color: above,
|
||||
weight: 2,
|
||||
dashArray: '5 5',
|
||||
opacity: 0.85,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(line);
|
||||
}
|
||||
|
||||
// Frame the widest ring — and the home pin when it falls outside it, so a
|
||||
// family beyond every cut-off can still see where they sit.
|
||||
const widest = ordered[0]?.radiusM ?? 500;
|
||||
let bounds = L.latLng(lat, lng).toBounds(widest * 2.2);
|
||||
if (home) bounds = bounds.extend(L.latLng(home.lat, home.lng));
|
||||
map.fitBounds(bounds, { padding: [16, 16] });
|
||||
}, [lat, lng, rings, home]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const handlers = [
|
||||
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
|
||||
];
|
||||
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
|
||||
|
||||
if (interactive && !zoomCtrlRef.current) {
|
||||
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
|
||||
zoomCtrlRef.current.addTo(map);
|
||||
} else if (!interactive && zoomCtrlRef.current) {
|
||||
zoomCtrlRef.current.remove();
|
||||
zoomCtrlRef.current = null;
|
||||
}
|
||||
setTimeout(() => map.invalidateSize(), 80);
|
||||
}, [interactive]);
|
||||
|
||||
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
@@ -22,10 +22,10 @@ interface LeafletHeroMapInnerProps {
|
||||
// reliably inside Leaflet's divIcon HTML string (no CSS-module dependency).
|
||||
const PIN_HTML = `
|
||||
<div style="position:relative;width:26px;height:26px">
|
||||
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.25)"></span>
|
||||
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
|
||||
<svg width="26" height="34" viewBox="0 0 26 34" style="filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))">
|
||||
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/>
|
||||
<circle cx="13" cy="13" r="5" fill="#fff"/>
|
||||
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="var(--brand)"/>
|
||||
<circle cx="13" cy="13" r="5" fill="var(--text-inverse)"/>
|
||||
</svg>
|
||||
</div>`;
|
||||
|
||||
|
||||
@@ -48,21 +48,21 @@ function buildPopupBadge(school: School): PopupBadge {
|
||||
// ofsted_framework is the raw event grouping, never "ReportCard").
|
||||
if (school.ofsted_rc_date) {
|
||||
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||||
return { label: `Report Card · ${rcYear}`, style: 'background:#5a3a6e;color:#fff' };
|
||||
return { label: `Report Card · ${rcYear}`, style: 'background:var(--phase-secondary-text);color:var(--text-inverse)' };
|
||||
}
|
||||
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:#2f7a2f',
|
||||
3: 'background:#fef3cd;color:#7a6800',
|
||||
4: 'background:#fde8e0;color:#e07256',
|
||||
1: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
2: 'background:var(--status-above-bg);color:var(--status-above)',
|
||||
3: 'background:var(--status-below-bg);color:var(--status-below)',
|
||||
4: 'background:var(--status-below);color:var(--text-inverse)',
|
||||
};
|
||||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||||
}
|
||||
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
|
||||
return { label: 'Not yet inspected', style: 'background:var(--bg-secondary);color:var(--text-muted)' };
|
||||
}
|
||||
|
||||
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
|
||||
@@ -99,7 +99,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
const refIcon = L.divIcon({
|
||||
html: `<div style="
|
||||
width: 20px; height: 20px;
|
||||
background: #e07256;
|
||||
background: var(--brand);
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
@@ -145,13 +145,13 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
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 colour = diff >= 0.5 ? 'var(--status-above)' : diff <= -0.5 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
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>
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Attainment 8</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
} else if (school.rwm_expected_pct != null) {
|
||||
@@ -159,7 +159,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
let deltaLine = '';
|
||||
if (nationalAvgRwm != null) {
|
||||
const diff = Math.round(rwm - nationalAvgRwm);
|
||||
const colour = diff >= 2 ? '#2d7d7d' : diff <= -2 ? '#e07256' : '#8a847a';
|
||||
const colour = diff >= 2 ? 'var(--status-above)' : diff <= -2 ? 'var(--brand)' : 'var(--text-muted)';
|
||||
const text =
|
||||
diff >= 2 ? `+${diff} pts vs national` :
|
||||
diff <= -2 ? `${diff} pts vs national` :
|
||||
@@ -167,8 +167,8 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
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>
|
||||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
|
||||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing & Maths</span>
|
||||
${deltaLine}
|
||||
</div>`;
|
||||
}
|
||||
@@ -177,14 +177,14 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
|
||||
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>
|
||||
<strong style="font-size:13px;color:var(--text-primary);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">
|
||||
<div style="font-size:11px;color:var(--text-muted);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>
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
</div>`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary, #f3ede4) 25%,
|
||||
rgba(224, 114, 86, 0.08) 50%,
|
||||
var(--bg-secondary, #f3ede4) 75%
|
||||
var(--bg-secondary) 25%,
|
||||
rgba(var(--brand-rgb), 0.08) 50%,
|
||||
var(--bg-secondary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
@@ -56,7 +56,7 @@
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@
|
||||
}
|
||||
|
||||
.skeletonListItem {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.listTitle {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The schoolcompare mark.
|
||||
*
|
||||
* This is the supplied brand artwork, not a reconstruction of it. Two
|
||||
* colourways were extracted from the logo sheet and live in public/brand:
|
||||
*
|
||||
* mark.png the primary lockup's mark — teal pin, white window and
|
||||
* path, green leaves. Reads correctly on Warm White,
|
||||
* white cards and Sand, and on both dark-theme grounds.
|
||||
* mark-on-dark.png the on-dark colourway — white pin with the counter
|
||||
* knocked through to the ground. Required wherever the
|
||||
* ground is teal, because there the teal pin's silhouette
|
||||
* disappears and only the white window survives.
|
||||
*
|
||||
* Both are written onto one 176×208 canvas with the artwork at the same
|
||||
* height, so they are drop-in swappable — see ASPECT below for why that
|
||||
* matters rather than merely being neat.
|
||||
*
|
||||
* `variant="auto"` serves the on-dark artwork to dark-theme viewers via a
|
||||
* <picture> source, so this needs no JavaScript and no client boundary.
|
||||
*
|
||||
* The wordmark beside it is live text in Manrope rather than the sheet's
|
||||
* raster: the written style guide specifies Manrope, and live text stays
|
||||
* selectable, scales cleanly and recolours with the theme.
|
||||
*
|
||||
* RESOLUTION CAVEAT: the largest instance on the supplied sheet is 153×189,
|
||||
* which is ample for the header (36px), the favicon and the share card, but
|
||||
* short of a 512px PWA icon — that one is upscaled and is slightly soft. Drop
|
||||
* a vector (SVG/AI/EPS) into public/brand and regenerate to fix it; every
|
||||
* consumer goes through this component or public/brand, so it is one swap.
|
||||
*/
|
||||
|
||||
const MARK_LIGHT = '/brand/mark.png';
|
||||
const MARK_ON_DARK = '/brand/mark-on-dark.png';
|
||||
|
||||
/**
|
||||
* Intrinsic size of the artwork files, used to derive width from height.
|
||||
*
|
||||
* Both colourways are deliberately written onto the SAME canvas at the same
|
||||
* artwork height, so this one ratio is correct for either of them. That is not
|
||||
* tidiness — it is required. `variant="auto"` renders a single <img> whose
|
||||
* srcset swaps the file underneath it, so the width/height attributes are
|
||||
* shared by both colourways and cannot be varied per file. When the two were
|
||||
* tightly cropped they had different ratios (0.810 and 0.875) and different
|
||||
* padding, which meant a wrong aspect hint before load — a reflow on load
|
||||
* under `width: auto` — and a visible jump in logo size whenever the OS theme
|
||||
* flipped. Re-crop or replace one file and you must re-normalise both.
|
||||
*/
|
||||
const ART_WIDTH = 176;
|
||||
const ART_HEIGHT = 208;
|
||||
const ASPECT = ART_WIDTH / ART_HEIGHT;
|
||||
|
||||
interface LogoMarkProps {
|
||||
className?: string;
|
||||
/** Rendered height in px. Width follows the artwork's aspect ratio. */
|
||||
size?: number;
|
||||
/**
|
||||
* Which colourway to serve.
|
||||
* - `auto` teal pin, swapping to the on-dark artwork in the dark theme
|
||||
* - `onDark` always the white pin — for teal grounds, which are teal in
|
||||
* both themes (the footer band)
|
||||
*/
|
||||
variant?: 'auto' | 'onDark';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function LogoMark({ className, size = 36, variant = 'auto', title }: LogoMarkProps) {
|
||||
const height = size;
|
||||
const width = Math.round(size * ASPECT);
|
||||
const alt = title ?? '';
|
||||
|
||||
if (variant === 'onDark') {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={className}
|
||||
src={MARK_ON_DARK}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={alt}
|
||||
aria-hidden={title ? undefined : true}
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcSet={MARK_ON_DARK} />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className={className}
|
||||
src={MARK_LIGHT}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={alt}
|
||||
aria-hidden={title ? undefined : true}
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(26, 22, 18, 0.6);
|
||||
background: rgba(var(--shadow-rgb), 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card, white);
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(26, 22, 18, 0.2);
|
||||
box-shadow: 0 20px 40px rgba(var(--shadow-rgb), 0.2);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
@@ -62,22 +62,22 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
@@ -87,8 +87,8 @@
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--accent-coral, #e07256);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -102,16 +102,16 @@
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted, #8a847a);
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/*
|
||||
* Header and mobile tab bar, on the schoolcompare brand.
|
||||
*
|
||||
* The header is a white card band closed by a single hairline rule — no
|
||||
* shadow. The guideline's chrome is quiet; the colour on this surface is the
|
||||
* wordmark and the one active nav item, and nothing else.
|
||||
*/
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-card, white);
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -16,112 +23,121 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
/* 64px desktop / 56px mobile is load-bearing: the school detail page's
|
||||
sticky section nav docks against it with a hardcoded `top` in
|
||||
SchoolDetailShell.module.css and schoolSections.module.css, and the
|
||||
scroll-spy observers use it as a rootMargin. Change it in all four
|
||||
places or not at all. */
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.625rem;
|
||||
/* 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;
|
||||
color: var(--text-primary);
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/*
|
||||
* The mark colours itself — pin in var(--brand), leaf knocked out in
|
||||
* var(--bg-card), which is this header's own ground. It takes no colour from
|
||||
* currentColor, so there is deliberately no `color` here.
|
||||
*/
|
||||
.logoIcon {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--accent-coral, #e07256);
|
||||
flex: 0 0 auto;
|
||||
/* The artwork is taller than it is wide (153:189), so height drives the box
|
||||
and width follows. Constraining both would squash the pin. */
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.logoIcon svg {
|
||||
width: 100%;
|
||||
.logoIcon img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* "schoolcompare", one word, lower case. Manrope 700 pulled tight — the
|
||||
* negative tracking is what makes the two halves read as a single word rather
|
||||
* than as two.
|
||||
*/
|
||||
.logoText {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.logoTextAccent {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.logo:hover .logoTextAccent {
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
gap: 0.4375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sliding underline effect */
|
||||
.navLink::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
height: 2px;
|
||||
background: var(--accent-coral, #e07256);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.25s ease;
|
||||
border-radius: 1px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: color var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.navLink:hover::after {
|
||||
transform: scaleX(1);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* The current page is marked twice — brand hue and a heavier weight — so the
|
||||
state never rests on colour alone. */
|
||||
.navLink.active {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
background: var(--accent-coral-bg);
|
||||
}
|
||||
|
||||
.navLink.active::after {
|
||||
transform: scaleX(1);
|
||||
color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--accent-coral, #e07256);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
box-shadow: 0 2px 6px rgba(224, 114, 86, 0.4);
|
||||
}
|
||||
|
||||
@keyframes badgePop {
|
||||
@@ -149,25 +165,23 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
padding: 0.375rem 0.25rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tab:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabIconWrap {
|
||||
@@ -179,7 +193,29 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* The active tab's icon sits in a brand-tinted stadium — the same rounded
|
||||
geometry as the rest of the system, and a second cue beyond hue. Drawn as a
|
||||
pseudo-element so it can bleed wider than the 24px icon box without moving
|
||||
the badge's anchor. */
|
||||
.tabIconWrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px -9px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: transparent;
|
||||
transition: background-color var(--transition);
|
||||
}
|
||||
|
||||
.tabActive .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tab:active .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
@@ -190,19 +226,21 @@
|
||||
|
||||
.tabBadge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--accent-coral, #e07256);
|
||||
border: 2px solid var(--bg-card, white);
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border: 2px solid var(--bg-card);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
}
|
||||
@@ -219,8 +257,12 @@
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
@@ -230,9 +272,9 @@
|
||||
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);
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -2px 12px rgba(var(--shadow-rgb), 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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Navigation.module.css';
|
||||
|
||||
type IconProps = { className?: string };
|
||||
@@ -78,6 +79,16 @@ export function Navigation() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* The brand guideline's nav also carries Guides, About, Favourites and a
|
||||
* "Sign in" button. None of the four is added here, deliberately: there are
|
||||
* no Guides or About pages, and Favourites and Sign in both need an accounts
|
||||
* system that does not exist. Drawing chrome for features we do not have
|
||||
* would be a promise the product cannot keep. Rankings and Admissions —
|
||||
* which the guideline omits — take their place, because they are real.
|
||||
*
|
||||
* If accounts ever ship, this is the list to extend.
|
||||
*/
|
||||
const items = [
|
||||
{ href: '/', label: 'Search', Icon: SearchIcon },
|
||||
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
||||
@@ -89,15 +100,19 @@ export function Navigation() {
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.container}>
|
||||
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
||||
<Link href="/" className={styles.logo} aria-label="schoolcompare home">
|
||||
{/*
|
||||
variant="auto": the teal pin on the white header, swapping to the
|
||||
on-dark artwork for dark-theme viewers. The footer has to force
|
||||
onDark instead, because its band is teal in both themes and the
|
||||
teal pin has no silhouette against it.
|
||||
*/}
|
||||
<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>
|
||||
<LogoMark size={38} />
|
||||
</span>
|
||||
<span className={styles.logoText}>
|
||||
school<span className={styles.logoTextAccent}>compare</span>
|
||||
</span>
|
||||
<span className={styles.logoText}>SchoolCompare</span>
|
||||
</Link>
|
||||
|
||||
<nav className={styles.nav} aria-label="Main navigation">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
.info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -21,18 +21,18 @@
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navButton:hover:not(:disabled) {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navButton:disabled {
|
||||
@@ -51,32 +51,32 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pageButton {
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pageButton:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pageButtonActive {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.pageButtonActive:hover {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
border-color: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
@@ -84,7 +84,7 @@
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
.trendSummary {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--brand);
|
||||
border-radius: 0 4px 4px 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
.covidNote,
|
||||
.chartHint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card, #fff);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
.chip:active {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
@@ -87,21 +87,21 @@
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
border-color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chipActive:active {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
.miniLegend {
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions, ChartDataset } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
@@ -124,9 +125,17 @@ export function PerformanceChart({
|
||||
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||||
const hasGap = gapYears.length > 0;
|
||||
|
||||
// Chart.js paints to a canvas, so it needs resolved colours rather than
|
||||
// var(). Reading them through the token bridge keeps the series on the
|
||||
// brand palette and lets them follow the theme.
|
||||
const [c1, c2, c3, c4, c5, c6, cGrid, cRef, cInverse, cInverseText] = useThemeTokens(
|
||||
'--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6',
|
||||
'--chart-grid', '--chart-reference', '--surface-inverse', '--text-inverse'
|
||||
);
|
||||
|
||||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||
const refLineStyle = {
|
||||
borderColor: 'rgba(90,80,70,0.35)',
|
||||
borderColor: cRef,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: [6, 4] as number[],
|
||||
@@ -140,8 +149,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Attainment 8',
|
||||
data: col('attainment_8_score'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderColor: c1,
|
||||
backgroundColor: alpha('--chart-1', 0.08),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
@@ -151,8 +160,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'English & Maths Grade 4+',
|
||||
data: col('english_maths_standard_pass_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderColor: c2,
|
||||
backgroundColor: alpha('--chart-2', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -161,8 +170,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Progress 8',
|
||||
data: col('progress_8_score'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderColor: c3,
|
||||
backgroundColor: alpha('--chart-3', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -179,8 +188,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Reading, Writing & Maths expected %',
|
||||
data: col('rwm_expected_pct'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderColor: c1,
|
||||
backgroundColor: alpha('--chart-1', 0.08),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
@@ -190,8 +199,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Exceeding expected level',
|
||||
data: col('rwm_high_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderColor: c2,
|
||||
backgroundColor: alpha('--chart-2', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -206,8 +215,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Reading progress',
|
||||
data: col('reading_progress'),
|
||||
borderColor: 'rgb(59,130,246)',
|
||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||
borderColor: c4,
|
||||
backgroundColor: alpha('--chart-4', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -217,8 +226,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Writing progress',
|
||||
data: col('writing_progress'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderColor: c3,
|
||||
backgroundColor: alpha('--chart-3', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -228,8 +237,8 @@ export function PerformanceChart({
|
||||
{
|
||||
label: 'Maths progress',
|
||||
data: col('maths_progress'),
|
||||
borderColor: 'rgb(236,72,153)',
|
||||
backgroundColor: 'rgba(236,72,153,0.08)',
|
||||
borderColor: c5,
|
||||
backgroundColor: alpha('--chart-5', 0.08),
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
@@ -301,7 +310,9 @@ export function PerformanceChart({
|
||||
},
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 12,
|
||||
titleFont: { size: 13 },
|
||||
bodyFont: { size: 12 },
|
||||
@@ -321,7 +332,7 @@ export function PerformanceChart({
|
||||
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)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
y1: {
|
||||
@@ -346,7 +357,9 @@ export function PerformanceChart({
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 11 },
|
||||
@@ -364,7 +377,7 @@ export function PerformanceChart({
|
||||
y: {
|
||||
type: 'linear', display: true, position: 'left',
|
||||
min: mobileYBounds.min, max: mobileYBounds.max,
|
||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||
grid: { color: cGrid },
|
||||
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
|
||||
},
|
||||
x: {
|
||||
@@ -429,9 +442,9 @@ export function PerformanceChart({
|
||||
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>
|
||||
<span><span className={styles.miniDot} style={{ background: c4 }} />Reading</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c3 }} />Writing</span>
|
||||
<span><span className={styles.miniDot} style={{ background: c5 }} />Maths</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
.header h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
@@ -38,8 +38,8 @@
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@@ -47,34 +47,34 @@
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral-dark, #b04a2e);
|
||||
color: white;
|
||||
background: var(--brand-strong);
|
||||
color: var(--brand-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral-darker, #9c3f26);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
@@ -88,7 +88,7 @@
|
||||
.filterLabel {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -98,44 +98,44 @@
|
||||
max-width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||
}
|
||||
|
||||
.filterSelect optgroup {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.filterSelect option {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
|
||||
/* Rankings Section */
|
||||
.rankingsSection {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
@@ -151,15 +151,15 @@
|
||||
}
|
||||
|
||||
.rankingsTable thead {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.rankingsTable th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--border);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
@@ -206,8 +206,8 @@
|
||||
|
||||
.rankingsTable td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rankingsTable tbody tr:last-child td {
|
||||
@@ -216,24 +216,24 @@
|
||||
|
||||
/* Alternating row backgrounds for visual rhythm */
|
||||
.rankingsTable tbody tr:nth-child(even) {
|
||||
background: rgba(243, 237, 228, 0.5);
|
||||
background: rgba(var(--text-inverse-rgb), 0.5);
|
||||
}
|
||||
|
||||
.rankingsTable tbody tr:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Top 3 Highlighting with Gold */
|
||||
.rank1 {
|
||||
background: linear-gradient(90deg, rgba(201, 162, 39, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--status-below-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rank2 {
|
||||
background: linear-gradient(90deg, rgba(192, 192, 192, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--medal-silver-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rank3 {
|
||||
background: linear-gradient(90deg, rgba(205, 127, 50, 0.15) 0%, transparent 100%) !important;
|
||||
background: linear-gradient(90deg, rgba(var(--medal-bronze-rgb), 0.15) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
.rankCell {
|
||||
@@ -241,7 +241,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Styled rank badges for top 3 */
|
||||
@@ -254,8 +254,8 @@
|
||||
border-radius: 50%;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
color: var(--brand-on);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -265,30 +265,31 @@
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), transparent) border-box;
|
||||
background: linear-gradient(135deg, rgba(var(--text-inverse-rgb), 0.4), transparent) border-box;
|
||||
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask-composite: xor;
|
||||
}
|
||||
|
||||
.rankBadge1 {
|
||||
background: linear-gradient(135deg, #c9a227 0%, #e8c547 50%, #c9a227 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-gold) 50%, var(--status-below) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankBadge2 {
|
||||
background: linear-gradient(135deg, #8c8c8c 0%, #c0c0c0 50%, #8c8c8c 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--text-muted) 0%, var(--medal-silver) 50%, var(--text-muted) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankBadge3 {
|
||||
background: linear-gradient(135deg, #a5673f 0%, #cd7f32 50%, #a5673f 100%);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-bronze) 50%, var(--status-below) 100%);
|
||||
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
|
||||
}
|
||||
|
||||
.rankNumber {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schoolCell {
|
||||
@@ -296,18 +297,18 @@
|
||||
}
|
||||
|
||||
.schoolLink {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.schoolLink:hover {
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.areaCell,
|
||||
.typeCell {
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* LA subline under the school name — mobile only (Area column hidden there). */
|
||||
@@ -315,7 +316,7 @@
|
||||
display: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.valueCell {
|
||||
@@ -324,7 +325,7 @@
|
||||
}
|
||||
|
||||
.valueCell strong {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--status-above);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -346,7 +347,7 @@
|
||||
.noResults {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.noResults p {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -41,20 +41,39 @@
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── Per-bar national average marker ──
|
||||
Each bar compares against its own benchmark (expected vs higher standard /
|
||||
greater depth), so the marker sits on the individual bar's track rather than
|
||||
as one line spanning both bars. */
|
||||
/*
|
||||
* Knockout, not a colour.
|
||||
*
|
||||
* This marker was var(--brand) — the same value as .barExpected and
|
||||
* .barExceeding, so wherever it crossed a bar it measured 1.00:1 and was not
|
||||
* rendered distinguishably at all. It scored 5.47:1 only against the empty
|
||||
* track, which means it was visible precisely when a school was BELOW the
|
||||
* national average and vanished for every school at or above it.
|
||||
*
|
||||
* No single colour fixes this, because the marker's position is data-driven:
|
||||
* it can land on the bar, on the empty track, or straddle the boundary. So it
|
||||
* is drawn as a knockout — a light core carrying a dark edge. On the teal bar
|
||||
* the core reads; on the pale track the edge reads. Both tokens flip with the
|
||||
* theme, so the pairing holds in dark mode too.
|
||||
*
|
||||
* The edge is box-shadow rather than border so it costs no layout width and
|
||||
* cannot shift the 50% translate.
|
||||
*/
|
||||
.natTick {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
width: 3px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent-coral, #e07256);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 0 0 1px var(--text-primary);
|
||||
border-radius: 2px;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
@@ -63,13 +82,14 @@
|
||||
.natTick::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-coral, #e07256);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 0 0 1px var(--text-primary);
|
||||
}
|
||||
|
||||
.barHeaderRight {
|
||||
@@ -81,7 +101,7 @@
|
||||
.natLabel {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
color: var(--brand-strong);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
@@ -122,19 +142,31 @@
|
||||
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
/*
|
||||
* Expected and Exceeding were both var(--brand) — one colour for two series,
|
||||
* distinguished only by which row you were looking at, while the legend
|
||||
* claimed two.
|
||||
*
|
||||
* They are a sequential pair, not two categories: "exceeding" is a subset of
|
||||
* the same cohort at a harder bar. So they take two steps of the same hue
|
||||
* rather than two different hues, with the harder measure the more intense
|
||||
* step. --brand-stronger is darker than --brand in the light theme and lighter
|
||||
* in the dark one, which is the right direction in both: further from the
|
||||
* ground.
|
||||
*/
|
||||
.barExpected {
|
||||
background: var(--accent-teal-light, #3a9e9e);
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.barExceeding {
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
background: var(--brand-stronger);
|
||||
}
|
||||
|
||||
.barLabel {
|
||||
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
@@ -142,7 +174,7 @@
|
||||
|
||||
.barLabelSuffix {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -153,7 +185,7 @@
|
||||
position: relative;
|
||||
height: 14px;
|
||||
margin-top: 0.3rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rulerTick {
|
||||
@@ -161,7 +193,7 @@
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 3px;
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.rulerLabel {
|
||||
@@ -169,7 +201,7 @@
|
||||
top: 5px;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
transform: translateX(-50%);
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0.7;
|
||||
@@ -198,7 +230,7 @@
|
||||
gap: 0.35rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #6d685f);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.legendSwatch {
|
||||
|
||||
@@ -143,17 +143,40 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
||||
<SubjectColumn key={subject.name} subject={subject} />
|
||||
))}
|
||||
</div>
|
||||
{/*
|
||||
The legend describes what is drawn, which it previously did not.
|
||||
|
||||
Both data swatches were var(--status-above) — green — while the bars
|
||||
they labelled were var(--brand) teal, and they were identical to each
|
||||
other, so two different series shared one swatch. Worse, the only
|
||||
swatch that matched the bar colour was the one labelled "National
|
||||
average": anyone reading the chart by matching colours would conclude
|
||||
the teal bars WERE the national average.
|
||||
|
||||
Each swatch now carries the exact value its bar carries, and the
|
||||
national-average swatch mirrors the knockout marker rather than being
|
||||
a flat colour, so it is recognisable as the thing on the chart.
|
||||
*/}
|
||||
<div className={styles.legend}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand)' }} />
|
||||
Expected standard
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand-stronger)' }} />
|
||||
Exceeding / high score
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||
<div
|
||||
className={styles.legendSwatch}
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
boxShadow: '0 0 0 1px var(--text-primary)',
|
||||
width: '3px',
|
||||
height: '12px',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
National average
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.card {
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.125rem;
|
||||
@@ -8,13 +8,13 @@
|
||||
}
|
||||
|
||||
.card.cardInCompare {
|
||||
border-color: var(--accent-teal, #2d7d7d);
|
||||
box-shadow: 0 0 0 1px var(--accent-teal, #2d7d7d);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 1px var(--brand);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-left-color: var(--accent-coral, #e07256);
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
||||
border-left-color: var(--brand);
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(var(--shadow-rgb), 0.1));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -31,24 +31,24 @@
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.title a:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.distance {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
color: var(--brand);
|
||||
white-space: nowrap;
|
||||
background: var(--accent-teal-bg);
|
||||
background: var(--brand-bg);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
@@ -63,9 +63,9 @@
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.875rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@@ -88,12 +88,13 @@
|
||||
|
||||
.metricLabel {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
@@ -101,7 +102,7 @@
|
||||
|
||||
.metricValue strong {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -126,18 +127,18 @@
|
||||
}
|
||||
|
||||
.trendUp {
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--status-above);
|
||||
background: var(--status-above-bg);
|
||||
}
|
||||
|
||||
.trendDown {
|
||||
color: var(--accent-coral, #e07256);
|
||||
background: var(--accent-coral-bg);
|
||||
color: var(--status-below);
|
||||
background: var(--status-below-bg);
|
||||
}
|
||||
|
||||
.trendStable {
|
||||
color: var(--text-muted, #8a847a);
|
||||
background: rgba(138, 132, 122, 0.15);
|
||||
color: var(--text-muted);
|
||||
background: rgba(var(--muted-rgb), 0.15);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -156,7 +157,7 @@
|
||||
|
||||
.metricHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-weight: 400;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
background: #dfe6e2;
|
||||
background: var(--bg-secondary);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) #e7e2da;
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
@@ -85,11 +85,11 @@
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, .85);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .16);
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .16);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity .16s ease, transform .16s ease;
|
||||
@@ -118,7 +118,7 @@
|
||||
rgba(255, 255, 255, .35) 35%,
|
||||
rgba(255, 255, 255, .75) 62%,
|
||||
rgba(255, 255, 255, .95) 82%,
|
||||
var(--bg-card, #fff) 100%);
|
||||
var(--bg-card) 100%);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
@@ -134,9 +134,9 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .2);
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.fullscreenBtn:hover {
|
||||
background: #f4f4f4;
|
||||
background: var(--bg-secondary);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid rgba(224, 114, 86, 0.3);
|
||||
border: 3px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-coral, #e07256);
|
||||
border-top-color: var(--brand);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
@@ -12,17 +12,17 @@
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
}
|
||||
|
||||
/* Phase border colours */
|
||||
.phasePrimary { border-left-color: var(--phase-primary, #5b8cbf); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
|
||||
.phaseNursery { border-left-color: var(--phase-nursery, #e0a0b0); }
|
||||
.phasePrimary { border-left-color: var(--phase-primary); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through); }
|
||||
.phaseNursery { border-left-color: var(--phase-nursery); }
|
||||
|
||||
.rowInCompare {
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left-color: var(--brand);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@keyframes rowFadeIn {
|
||||
@@ -50,7 +50,7 @@
|
||||
.schoolName {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
|
||||
.schoolName:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Phase label pill */
|
||||
@@ -96,8 +96,8 @@
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: rgba(26, 22, 18, 0.05);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: rgba(var(--shadow-rgb), 0.05);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Line 3: stats */
|
||||
@@ -115,10 +115,11 @@
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
@@ -126,7 +127,7 @@
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -137,9 +138,9 @@
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.trendUp { color: var(--accent-teal, #2d7d7d); }
|
||||
.trendDown { color: var(--accent-coral, #e07256); }
|
||||
.trendStable { color: var(--text-muted, #8a847a); }
|
||||
.trendUp { color: var(--status-above); }
|
||||
.trendDown { color: var(--status-below); }
|
||||
.trendStable { color: var(--text-muted); }
|
||||
|
||||
/* Line 4: location */
|
||||
.line4 {
|
||||
@@ -148,13 +149,13 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.line4 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.distanceBadge {
|
||||
@@ -162,8 +163,8 @@
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -200,23 +201,23 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
|
||||
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
|
||||
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
|
||||
|
||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||
/* ofsted1–4 already defined above; these cover the two new framework states */
|
||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
||||
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
||||
distinct from the grey "Not yet inspected" pending state. */
|
||||
.ofstedInspected { background: #e6ecf2; color: #45586b; }
|
||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
||||
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||
.ofstedPending { background: var(--border); color: var(--text-muted); }
|
||||
|
||||
/* ── 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); }
|
||||
.vsNational { font-size: 0.7rem; color: var(--status-above); font-weight: 600; }
|
||||
.vsNationalNeg { font-size: 0.7rem; color: var(--brand); font-weight: 600; }
|
||||
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
@@ -257,7 +258,7 @@
|
||||
|
||||
/* GIAS "Open, but proposed to close" marker */
|
||||
.attrClosing {
|
||||
background: #fdf6e3;
|
||||
color: #8a6200;
|
||||
border: 1px solid #e2c96f;
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
border: 1px solid var(--status-below-bg);
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--accent-gold-bg);
|
||||
border: 1px solid var(--accent-gold, #c9a227);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--status-below-bg);
|
||||
border: 1px solid var(--status-below);
|
||||
color: var(--text-primary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
@@ -21,21 +21,21 @@
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||
}
|
||||
|
||||
.searchSpinner {
|
||||
@@ -45,9 +45,9 @@
|
||||
transform: translateY(-50%);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid rgba(224, 114, 86, 0.3);
|
||||
border: 2px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-coral, #e07256);
|
||||
border-top-color: var(--brand);
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@@ -71,17 +71,17 @@
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted, #8a847a);
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.resultItem {
|
||||
@@ -90,16 +90,16 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.resultItem:hover {
|
||||
background: var(--bg-card, white);
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||
background: var(--bg-card);
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
}
|
||||
|
||||
.schoolInfo {
|
||||
@@ -110,7 +110,7 @@
|
||||
.schoolName {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -125,7 +125,7 @@
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schoolMeta span {
|
||||
@@ -138,14 +138,14 @@
|
||||
.noResults {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,972 +0,0 @@
|
||||
/**
|
||||
* SecondarySchoolDetailView Component
|
||||
* Dedicated detail view for secondary schools with scroll-to-section navigation.
|
||||
* All sections render at once; the sticky nav scrolls to each.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { MetricTooltip } from './MetricTooltip';
|
||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
|
||||
|
||||
const PerformanceChart = dynamic(
|
||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData,
|
||||
OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions,
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SecondarySchoolDetailView.module.css';
|
||||
|
||||
const OFSTED_LABELS: Record<number, string> = {
|
||||
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
||||
};
|
||||
|
||||
const RC_LABELS: Record<number, string> = {
|
||||
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
|
||||
};
|
||||
|
||||
const RC_CATEGORIES = [
|
||||
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
|
||||
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
|
||||
{ key: 'rc_achievement' as const, label: 'Achievement' },
|
||||
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
|
||||
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
|
||||
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
|
||||
{ key: 'rc_early_years' as const, label: 'Early Years' },
|
||||
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
|
||||
];
|
||||
|
||||
function progressClass(val: number | null | undefined, modStyles: Record<string, string>): string {
|
||||
if (val == null) return '';
|
||||
if (val > 0) return modStyles.progressPositive;
|
||||
if (val < 0) return modStyles.progressNegative;
|
||||
return '';
|
||||
}
|
||||
|
||||
function deprivationDesc(decile: number): string {
|
||||
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
||||
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
||||
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
||||
}
|
||||
|
||||
interface SecondarySchoolDetailViewProps {
|
||||
schoolInfo: School;
|
||||
yearlyData: SchoolResult[];
|
||||
absenceData: AbsenceData | null;
|
||||
ofsted: OfstedInspection | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
}
|
||||
|
||||
export function SecondarySchoolDetailView({
|
||||
schoolInfo, yearlyData,
|
||||
ofsted, census, admissions, deprivation, finance, absenceData,
|
||||
}: SecondarySchoolDetailViewProps) {
|
||||
const router = useRouter();
|
||||
// Hero map — the "View on map" link opens its fullscreen view.
|
||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||
const isInComparison = isSelected(schoolInfo.urn);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<string>('');
|
||||
// Header details collapse behind a "Show all details" link on mobile/tablet.
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
|
||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/national-averages')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setNationalAvg(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
|
||||
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
|
||||
const hasSixthForm = schoolInfo.has_sixth_form ?? false;
|
||||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
|
||||
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
||||
const hasResults = latestResults?.attainment_8_score != null;
|
||||
|
||||
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
|
||||
// so their headline attainment is far below the mainstream average by design.
|
||||
// Drop the England comparison + "below" framing so the page doesn't portray
|
||||
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
|
||||
// single 0–80 score with no subject breakdown to test for a placeholder, so
|
||||
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
|
||||
// mainstream school still shows its real value and comparison.
|
||||
const isSpecial = isSpecialSchool(schoolInfo);
|
||||
const suppressComparison = isSpecial;
|
||||
|
||||
const admissionsTag = (() => {
|
||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||
if (policy.includes('selective')) return 'Selective';
|
||||
const denom = schoolInfo.religious_denomination ?? '';
|
||||
if (denom && denom !== 'Does not apply') return 'Faith priority';
|
||||
return null;
|
||||
})();
|
||||
|
||||
const handleComparisonToggle = () => {
|
||||
if (isInComparison) {
|
||||
removeSchool(schoolInfo.urn);
|
||||
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||
} else {
|
||||
addSchool(schoolInfo);
|
||||
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||||
}
|
||||
};
|
||||
|
||||
// Back returns wherever the user came from; deep-links fall back to search
|
||||
// so the button never dead-ends or leaves the site.
|
||||
const handleBack = () => {
|
||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/search');
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
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.
|
||||
// Engagement-led order (matches the primary page): recognised Ofsted badge,
|
||||
// then the most-sought sections — results, admissions, history — with the
|
||||
// experience and context sections following.
|
||||
const navItems: { id: string; label: string }[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
||||
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||
|
||||
// Track active section as user scrolls
|
||||
useEffect(() => {
|
||||
const ids = navItems.map(n => n.id);
|
||||
if (!ids.length) return;
|
||||
const observers: IntersectionObserver[] = [];
|
||||
const ratioMap: Record<string, number> = {};
|
||||
const pickActive = () => {
|
||||
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
|
||||
setActiveSection(top?.[1] > 0 ? top[0] : '');
|
||||
};
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
ratioMap[id] = 0;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => { ratioMap[id] = entry.intersectionRatio; pickActive(); },
|
||||
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
|
||||
);
|
||||
obs.observe(el);
|
||||
observers.push(obs);
|
||||
});
|
||||
return () => observers.forEach(o => o.disconnect());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [navItems.map(n => n.id).join(',')]);
|
||||
|
||||
// A report card is identified by the presence of report-card area
|
||||
// judgements, NOT by `framework` — the API sets `framework` to the raw
|
||||
// event grouping (e.g. "Schools - S5") even for report-card schools, so
|
||||
// the old `framework === 'ReportCard'` test never matched and report cards
|
||||
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
|
||||
const isReportCard = !!(
|
||||
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
|
||||
);
|
||||
// Report cards are dated by their own inspection (rc_inspection_date), never
|
||||
// the legacy inspection_date (report cards exist only from Nov 2025).
|
||||
const ofstedInspectedDate = isReportCard
|
||||
? ofsted?.rc_inspection_date ?? null
|
||||
: ofsted?.inspection_date ?? null;
|
||||
|
||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||
const oeifAllSameGrade =
|
||||
!!ofsted &&
|
||||
!isReportCard &&
|
||||
oeifAreas.length >= 3 &&
|
||||
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||
|
||||
// National Attainment 8 baseline for the "Results Over Time" chart.
|
||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Standalone back link, above the header — returns wherever the user
|
||||
came from. Scrolls away; the sticky bar keeps a "back to top" control. */}
|
||||
<button type="button" onClick={handleBack} className={styles.topBack}>
|
||||
<span aria-hidden="true">←</span> Back
|
||||
</button>
|
||||
|
||||
{/* ── Header — the location map band blends into the school title ── */}
|
||||
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
|
||||
{hasLocation && (
|
||||
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
|
||||
)}
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||
<div className={styles.badges}>
|
||||
{schoolInfo.school_type && (
|
||||
<span className={styles.badge}>{schoolInfo.school_type}</span>
|
||||
)}
|
||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||
<span className={styles.badge}>{schoolInfo.gender}'s school</span>
|
||||
)}
|
||||
{schoolInfo.age_range && (
|
||||
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||||
)}
|
||||
{schoolInfo.nursery_provision && (
|
||||
<span className={styles.badge}>Nursery</span>
|
||||
)}
|
||||
{hasSixthForm && (
|
||||
<span className={styles.badge}>Sixth form</span>
|
||||
)}
|
||||
{admissionsTag && (
|
||||
<span className={`${styles.badge} ${admissionsTag === 'Selective' ? styles.badgeSelective : styles.badgeFaith}`}>
|
||||
{admissionsTag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isProposedToClose(schoolInfo) && (
|
||||
<div className={styles.closingStrip} role="note">
|
||||
<strong>⚠ Proposed to close</strong> — this school is proposed for closure,
|
||||
check with the local authority before applying.
|
||||
</div>
|
||||
)}
|
||||
{schoolInfo.address && (
|
||||
<p className={styles.address}>
|
||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||
{hasLocation && (
|
||||
<>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mapLink}
|
||||
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
|
||||
>
|
||||
View on map ↗
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailsToggle}
|
||||
aria-expanded={detailsOpen}
|
||||
aria-controls="school-header-details"
|
||||
onClick={() => setDetailsOpen((o) => !o)}
|
||||
>
|
||||
{detailsOpen ? 'Hide details' : 'Show all details'}
|
||||
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
<div
|
||||
id="school-header-details"
|
||||
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
|
||||
>
|
||||
{schoolInfo.headteacher_name && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.website && (
|
||||
<span className={styles.headerDetail}>
|
||||
<a
|
||||
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="school_website"
|
||||
>
|
||||
School website ↗
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Pupils:</strong> {(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()}
|
||||
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.trust_name && (
|
||||
<span className={styles.headerDetail}>
|
||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.telephone && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Phone:</strong>{' '}
|
||||
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
||||
{schoolInfo.telephone}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.religious_denomination && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Religious character:</strong>{' '}
|
||||
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
||||
? 'None'
|
||||
: schoolInfo.religious_denomination}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.county && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>County:</strong> {schoolInfo.county}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.parliamentary_constituency && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
onClick={handleComparisonToggle}
|
||||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||||
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
||||
>
|
||||
{/* On phones the map hero shows only the glyph (nav-bar style). */}
|
||||
<span className={styles.btnCompareLabel}>
|
||||
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
||||
</span>
|
||||
<span className={styles.btnCompareGlyph} aria-hidden="true">
|
||||
{isInComparison ? '✓' : '+'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Sticky section navigation ─────────────────────── */}
|
||||
<nav className={styles.tabNav} aria-label="Page sections">
|
||||
<div className={styles.tabNavInner}>
|
||||
<button onClick={scrollToTop} className={styles.backBtn} aria-label="Back to top">↑ Top</button>
|
||||
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
|
||||
{navItems.map(({ id, label }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
||||
onClick={() => track('section_nav_used', { section: id })}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── Ofsted ─────────────────────────────────────── */}
|
||||
{ofsted && (
|
||||
<section id="ofsted" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||
{ofstedInspectedDate && (
|
||||
<span className={styles.ofstedDate}>
|
||||
{' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={`https://reports.ofsted.gov.uk/inspection-reports/find-inspection-report/provider/ELS/${schoolInfo.urn}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.ofstedReportLink}
|
||||
data-umami-event="external_link_clicked"
|
||||
data-umami-event-target="ofsted"
|
||||
>
|
||||
Ofsted reports ↗
|
||||
</a>
|
||||
</h2>
|
||||
{isReportCard ? (
|
||||
<>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||
</p>
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{ofsted.rc_safeguarding_met != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Safeguarding</div>
|
||||
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
|
||||
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{RC_CATEGORIES.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).map(({ key, label }) => {
|
||||
const value = ofsted[key] as number | null;
|
||||
return value != null ? (
|
||||
<div key={key} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
|
||||
{RC_LABELS[value]}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : ofsted.overall_effectiveness ? (
|
||||
<>
|
||||
<div className={styles.ofstedHeader}>
|
||||
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
|
||||
{OFSTED_LABELS[ofsted.overall_effectiveness]}
|
||||
</span>
|
||||
{ofsted.previous_overall != null &&
|
||||
ofsted.previous_overall !== ofsted.overall_effectiveness && (
|
||||
<span className={styles.ofstedPrevious}>
|
||||
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
{ofsted.grade_source === 'ungraded_carried_forward'
|
||||
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
|
||||
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'}
|
||||
</p>
|
||||
{oeifAllSameGrade ? (
|
||||
<p className={styles.ofstedAllSame}>
|
||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||
</p>
|
||||
) : (
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{oeifAreas.map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
{OFSTED_LABELS[value]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
From September 2024, Ofsted no longer gives a single overall grade.
|
||||
</p>
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{[
|
||||
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
||||
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
||||
{ label: 'Personal Development', value: ofsted.personal_development },
|
||||
{ label: 'Leadership & Management', value: ofsted.leadership_management },
|
||||
].filter(({ value }) => value != null).map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
{OFSTED_LABELS[value!]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── GCSE Results ───────────────────────────────── */}
|
||||
{hasResults && latestResults && (
|
||||
<section id="gcse" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||||
</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
|
||||
</p>
|
||||
|
||||
<SpecialSchoolNote school={schoolInfo} />
|
||||
|
||||
{p8Suspended && (
|
||||
<div className={styles.p8Banner}>
|
||||
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
||||
(COVID), so DfE has no starting point to measure their progress from.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero stat cards — top GCSE metrics */}
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults.attainment_8_score != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Attainment 8 score
|
||||
<MetricTooltip metricKey="attainment_8_score" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{latestResults.attainment_8_score.toFixed(1)}
|
||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.attainment_8_score}
|
||||
baseline={secondaryAvg.attainment_8_score}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.progress_8_score != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Progress 8 score
|
||||
<MetricTooltip metricKey="progress_8_score" />
|
||||
</div>
|
||||
<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.heroStatHint}>
|
||||
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.heroStatHint}>National baseline: 0.0</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{latestResults.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
English & Maths Grade 5+
|
||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.english_maths_strong_pass_pct}
|
||||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||||
unit="pts"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>England 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)}
|
||||
{!suppressComparison && 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>
|
||||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
|
||||
"school vs national", so it's dropped for special schools where
|
||||
that comparison isn't meaningful. */}
|
||||
{!suppressComparison && 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) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{[
|
||||
{ label: 'English', val: latestResults.progress_8_english },
|
||||
{ label: 'Maths', val: latestResults.progress_8_maths },
|
||||
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
|
||||
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
|
||||
].filter(r => r.val != null).map(({ label, val }) => (
|
||||
<div key={label} className={styles.metricRow}>
|
||||
<span className={styles.metricName}>{label}</span>
|
||||
<span className={`${styles.metricValue} ${progressClass(val, styles)}`}>
|
||||
{formatProgress(val!)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* EBacc */}
|
||||
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
||||
English Baccalaureate (EBacc)
|
||||
<MetricTooltip metricKey="ebacc_entry_pct" />
|
||||
</h3>
|
||||
<div className={styles.metricTable}>
|
||||
{latestResults.ebacc_entry_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_standard_pass_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc Grade 4+</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_strong_pass_pct != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc Grade 5+</span>
|
||||
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
||||
</div>
|
||||
)}
|
||||
{latestResults.ebacc_avg_score != null && (
|
||||
<div className={styles.metricRow}>
|
||||
<span className={styles.metricName}>EBacc average point score</span>
|
||||
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Admissions ─────────────────────────────────── */}
|
||||
{admissions && (
|
||||
<section id="admissions" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Admissions</h2>
|
||||
|
||||
{admissionsTag && (
|
||||
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
|
||||
<strong>{admissionsTag}</strong>{' '}
|
||||
{admissionsTag === 'Selective'
|
||||
? '— Entry to this school is by selective examination (e.g. 11+).'
|
||||
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.metricsGrid}>
|
||||
{admissions.places_offered != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Year 7 places offered</div>
|
||||
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total applications</div>
|
||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>1st preference applications</div>
|
||||
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Families who got their first choice</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{admissions.oversubscribed != null && (
|
||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||
{admissions.oversubscribed
|
||||
? '⚠ Applications exceeded places last year'
|
||||
: '✓ Places were available last year'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
||||
</p>
|
||||
|
||||
{hasSixthForm && (
|
||||
<div className={styles.sixthFormNote}>
|
||||
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── History table ──────────────────────────────── */}
|
||||
{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={suppressComparison ? null : heroAtt8Nat}
|
||||
nationalByYear={suppressComparison ? undefined : 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>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
<th>Eng & Maths 4+</th>
|
||||
<th>EBacc entry %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{yearlyData.map((result) => (
|
||||
<tr key={result.year}>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||
<td>{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||
<td>{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||
<td>{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
)}
|
||||
{/* ── Wellbeing ──────────────────────────────────── */}
|
||||
{hasWellbeing && (
|
||||
<section id="wellbeing" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Wellbeing & Context</h2>
|
||||
|
||||
{/* SEN */}
|
||||
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults?.sen_support_pct != null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
SEN support
|
||||
<MetricTooltip metricKey="sen_support_pct" />
|
||||
</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.heroStatCard}>
|
||||
<div className={styles.heroStatLabel}>
|
||||
Pupils with EHCP
|
||||
<MetricTooltip metricKey="sen_ehcp_pct" />
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
||||
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deprivation */}
|
||||
{hasDeprivation && deprivation && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>
|
||||
Local Area Context
|
||||
<MetricTooltip metricKey="idaci_decile" />
|
||||
</h3>
|
||||
<div className={styles.deprivationDots}>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
|
||||
title={`Decile ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.deprivationScaleLabel}>
|
||||
<span>Most deprived</span>
|
||||
<span>Least deprived</span>
|
||||
</div>
|
||||
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Finances ───────────────────────────────────── */}
|
||||
{hasFinance && finance && (
|
||||
<section id="finances" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Per-pupil spending shows how much the school has to spend on each child's education.
|
||||
</p>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total spend per pupil per year</div>
|
||||
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
|
||||
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
|
||||
</div>
|
||||
{finance.teacher_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
|
||||
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{finance.staff_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
|
||||
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
{finance.premises_cost_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Share of budget spent on premises</div>
|
||||
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
@@ -12,17 +12,17 @@
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
}
|
||||
|
||||
/* Phase border colours */
|
||||
.phaseSecondary { border-left-color: var(--phase-secondary, #9b6bb0); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
|
||||
.phasePost16 { border-left-color: var(--phase-post16, #c4915e); }
|
||||
.phaseSecondary { border-left-color: var(--phase-secondary); }
|
||||
.phaseAllThrough { border-left-color: var(--phase-all-through); }
|
||||
.phasePost16 { border-left-color: var(--phase-post16); }
|
||||
|
||||
.rowInCompare {
|
||||
border-left-color: var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left-color: var(--brand);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@keyframes rowFadeIn {
|
||||
@@ -50,7 +50,7 @@
|
||||
.schoolName {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
|
||||
.schoolName:hover {
|
||||
color: var(--accent-coral, #e07256);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Line 2: context tags */
|
||||
@@ -81,8 +81,8 @@
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: rgba(26, 22, 18, 0.05);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: rgba(var(--shadow-rgb), 0.05);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Line 3: KS4 stats */
|
||||
@@ -100,22 +100,24 @@
|
||||
}
|
||||
|
||||
.statValueLarge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -125,8 +127,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deltaPositive { color: #2f7a2f; }
|
||||
.deltaNegative { color: var(--accent-coral, #e07256); }
|
||||
.deltaPositive { color: var(--status-above); }
|
||||
.deltaNegative { color: var(--status-below); }
|
||||
|
||||
/* Line 4: location + distance */
|
||||
.line4 {
|
||||
@@ -135,13 +137,13 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.line4 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.distanceBadge {
|
||||
@@ -149,8 +151,8 @@
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: white;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -175,15 +177,15 @@
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
background: rgba(26, 22, 18, 0.05);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
background: rgba(var(--shadow-rgb), 0.05);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selectiveTag {
|
||||
background: rgba(180, 120, 0, 0.1);
|
||||
color: #8a6200;
|
||||
background: rgba(var(--status-below-rgb), 0.1);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
/* ── Ofsted badge ────────────────────────────────────── */
|
||||
@@ -202,17 +204,17 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
|
||||
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
|
||||
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
|
||||
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
|
||||
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
|
||||
|
||||
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
|
||||
.ofstedRc { background: #5a3a6e; color: #fff; }
|
||||
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
|
||||
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
|
||||
distinct from the grey "Not yet inspected" pending state. */
|
||||
.ofstedInspected { background: #e6ecf2; color: #45586b; }
|
||||
.ofstedPending { background: #e0e0e0; color: #666; }
|
||||
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||
.ofstedPending { background: var(--border); color: var(--text-muted); }
|
||||
|
||||
/* ── Right actions column ────────────────────────────── */
|
||||
.rowActions {
|
||||
@@ -268,7 +270,7 @@
|
||||
}
|
||||
|
||||
.closingTag {
|
||||
background: #fdf6e3;
|
||||
color: #8a6200;
|
||||
border: 1px solid #e2c96f;
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
border: 1px solid var(--status-below-bg);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
/* Special-school / PRU / AP context note: soft, informational (teal), not a
|
||||
warning. Shared by both detail views so the styling can't drift. */
|
||||
.note {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left: 4px solid var(--accent-teal, #2d7d7d);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 4px solid var(--brand);
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 0.7rem 1rem;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.note strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
.moreMeasures {
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: var(--accent-coral-dark);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.stripNote {
|
||||
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user