Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 5 914b429a28 fix(home): art-direct the hero's fallback path, and declare sharp
Two findings from review on #93, both verified before fixing.

<img src> cannot vary by viewport, so it was always the wide desktop crop. A
browser taking neither AVIF nor WebP therefore fell through to the desktop
frame on a phone and lost the schoolhouse — the exact failure the two-crop
<picture> exists to prevent, surviving in the one path nobody looks at. The
band JPEG the build script already emitted was never referenced, which was the
tell. It now backs a <source media> placed after the modern formats, so they
still win wherever they are supported.

Verified by stripping the AVIF and WebP <source>s at runtime and letting
<picture> re-resolve, which is what an old browser actually sees:

  phone    hero-band-500.avif  →  hero-band-700.jpg   (band crop, school kept)
  desktop  hero-wide-1672.avif →  hero-wide-1200.jpg

sharp was not declared: it arrives transitively from next@16.1.6, so the
documented regeneration command works today and breaks on a Next upgrade or a
clean install that resolves differently. Declared in devDependencies for the
same reason next.config.js already declares its traced font files rather than
trusting the tracer to keep finding them.

The third finding — that the hero's licence is marked unconfirmed in
CREDITS.md while the artwork ships — is accurate and deliberate. It is the
owner's to answer; recording it as unknown is the point of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:46:04 +01:00
114 changed files with 236 additions and 10102 deletions

No files matched your search

-6
View File
@@ -5,9 +5,3 @@ __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/
+51 -283
View File
@@ -7,7 +7,6 @@ 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
@@ -35,7 +34,6 @@ 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
@@ -50,20 +48,11 @@ PHASE_GROUPS: dict[str, set[str]] = {
"all-through": {"all-through"},
}
# 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"
BASE_URL = "https://schoolcompare.co.uk"
MAX_SLUG_LENGTH = 60
# 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")
# In-memory sitemap cache
_sitemap_xml: str | None = None
def _slugify(text: str) -> str:
@@ -81,191 +70,43 @@ def _school_url(urn: int, school_name: str) -> str:
return f"/school/{urn}-{slug}"
# 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]:
return [
_url_element(BASE_URL + _place_url(p))
for p in sorted(get_place_registry().values(),
key=lambda p: (p.kind, p.slug))
if p.kind in kinds
]
def build_sitemaps() -> dict[str, str]:
"""Build the sitemap index and every child, keyed by name."""
def build_sitemap() -> str:
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
df = load_school_data()
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
static_urls = [
(BASE_URL + "/", "daily", "1.0"),
(BASE_URL + "/rankings", "weekly", "0.8"),
(BASE_URL + "/compare", "weekly", "0.8"),
]
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">']
def build_sitemap() -> str:
"""The sitemap index. Kept for `lifespan` and the admin endpoint."""
return build_sitemaps()["sitemap.xml"]
for url, freq, priority in static_urls:
lines.append(
f" <url><loc>{url}</loc>"
f"<changefreq>{freq}</changefreq>"
f"<priority>{priority}</priority></url>"
)
if not df.empty and "urn" in df.columns and "school_name" in df.columns:
seen = set()
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
urn = int(row["urn"])
name = str(row["school_name"])
if urn in seen:
continue
seen.add(urn)
path = _school_url(urn, name)
lines.append(
f" <url><loc>{BASE_URL}{path}</loc>"
f"<changefreq>monthly</changefreq>"
f"<priority>0.6</priority></url>"
)
lines.append("</urlset>")
return "\n".join(lines)
def clean_filter_values(series: pd.Series) -> list[str]:
@@ -443,7 +284,7 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan - startup and shutdown events."""
global _sitemaps
global _sitemap_xml
print("Loading school data from marts...")
df = load_school_data()
if df.empty:
@@ -453,9 +294,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:
_sitemaps = build_sitemaps()
n = sum(x.count("<url>") for x in _sitemaps.values())
print(f"Sitemaps built: {len(_sitemaps)} files, {n} URLs.")
_sitemap_xml = build_sitemap()
n = _sitemap_xml.count("<url>")
print(f"Sitemap built: {n} URLs.")
except Exception as e:
print(f"Warning: sitemap build failed on startup: {e}")
@@ -791,7 +632,6 @@ 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"),
@@ -1166,62 +1006,6 @@ 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},
"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):
@@ -1302,28 +1086,16 @@ async def robots_txt():
return FileResponse(settings.frontend_dir / "robots.txt", media_type="text/plain")
def _serve_sitemap(name: str) -> Response:
global _sitemaps
if _sitemaps is None:
try:
_sitemaps = build_sitemaps()
except Exception as e:
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
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)
"""Serve sitemap.xml for search engine indexing."""
global _sitemap_xml
if _sitemap_xml is None:
try:
_sitemap_xml = build_sitemap()
except Exception as e:
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
return Response(content=_sitemap_xml, media_type="application/xml")
@app.post("/api/admin/regenerate-sitemap")
@@ -1333,14 +1105,10 @@ 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 _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)}
global _sitemap_xml
_sitemap_xml = build_sitemap()
n = _sitemap_xml.count("<url>")
return {"status": "ok", "urls": n}
# Mount static files directly (must be after all routes to avoid catching API calls)
+1 -39
View File
@@ -18,7 +18,7 @@ from .config import settings
from .database import SessionLocal, engine
from .models import (
DimSchool, DimLocation, KS2Performance,
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
FactOfstedInspection, FactAdmissions,
FactDeprivation, FactFinance, FactPupilCharacteristics,
)
from .ofsted_codes import ofsted_page_url, report_card_labels
@@ -723,17 +723,6 @@ 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,
@@ -770,7 +759,6 @@ def _empty_supplementary() -> dict:
"census": None,
"admissions": None,
"admissions_history": [],
"admission_distance": None,
"sen_detail": None,
"phonics": None,
"deprivation": None,
@@ -849,32 +837,6 @@ 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 = (
-54
View File
@@ -1,54 +0,0 @@
"""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")),
}
-31
View File
@@ -188,37 +188,6 @@ 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"
-176
View File
@@ -1,176 +0,0 @@
"""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
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
@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 _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,
)
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))
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))
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
-231
View File
@@ -1,231 +0,0 @@
"""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"
)
-71
View File
@@ -1,71 +0,0 @@
"""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
-270
View File
@@ -1,270 +0,0 @@
"""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]
+5 -70
View File
@@ -8,31 +8,8 @@ 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.
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.
"""
"""Records that a query ran and serves canned rows filtered by an in-list."""
def __init__(self, recorder, model_name, rows):
self._rec = recorder
@@ -42,20 +19,7 @@ class _FakeQuery:
def filter(self, *args, **kwargs):
return self
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
)
def order_by(self, *args, **kwargs):
return self
def all(self):
@@ -105,13 +69,6 @@ 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).
@@ -121,14 +78,6 @@ 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": [],
@@ -136,10 +85,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 — six total, regardless of two URNs.
# Exactly one query per table — five total, regardless of two URNs.
assert sorted(session.queries) == [
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
"FactAdmissions", "FactDeprivation", "FactFinance",
"FactOfstedInspection", "FactPupilCharacteristics",
]
# Latest Ofsted kept per URN
@@ -151,18 +100,6 @@ 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
@@ -172,5 +109,3 @@ 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
-85
View File
@@ -1,85 +0,0 @@
# 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
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 ("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).
- 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).
- 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 "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.
- 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.
- 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)
@@ -1,309 +0,0 @@
# 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?
@@ -1,278 +0,0 @@
# 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.
+4 -791
View File
@@ -121,33 +121,6 @@ test('the home page has a real title', async ({ page }) => {
expect(await page.title()).toMatch(/schoolcompare/i);
});
test('the site states its independence and never claims to be official', async ({ page }) => {
await page.goto('/');
// Carried in the footer, so it appears on every page rather than only here.
await expect(
page.getByText(/an independent site\.\s*not affiliated with the department for education/i)
).toBeVisible();
/*
* Describing the DATA as official is accurate and appears throughout
* ("official DfE figures", "the official figure isn't in"). Describing the
* SITE as official is not: schoolcompare republishes government data, it is
* not a government service. The difference is the grammatical subject, so
* this looks for text that opens with "Official" as a standalone label —
* the shape a value-prop title or heading takes, and the shape the old
* "Official & trusted" took.
*/
const claims = await page.evaluate(() =>
[...document.querySelectorAll('body *')]
.flatMap(el => [...el.childNodes]
.filter(n => n.nodeType === 3)
.map(n => (n.textContent ?? '').trim()))
.filter(t => /^Official\b/i.test(t) && !/^Official\s+(DfE|data|figures)/i.test(t))
);
expect(claims, `text presenting the site itself as official: ${claims.join(' | ')}`).toEqual([]);
});
test('the landing bands share one vertical rhythm', async ({ page }) => {
await page.goto('/');
const gaps = await page.evaluate(() => {
@@ -800,108 +773,6 @@ test('sticky section nav jumps to server-rendered sections', async ({ page }) =>
* These are silent failures — nothing on the page looks wrong — so they need
* a gate.
*/
/**
* Chart benchmark markers, against whatever they actually overlap.
*
* The national-average marker shipped as var(--brand) on both templates —
* identical to the bar fill it sits on, so it measured 1.00:1 wherever it
* crossed a bar and was visible only for schools BELOW the benchmark.
*
* The WCAG journey below never saw it, and could not have: it composites
* backgrounds by walking the ANCESTOR chain, while these markers are
* absolutely positioned over a SIBLING. Ancestor-walking is structurally blind
* to overlap. This test asks the stacking order instead —
* document.elementsFromPoint returns what is genuinely beneath a point — which
* is the same question a reader's eye asks.
*/
const MARKER_PROBE = `(() => {
const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null;
const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; };
const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1});
const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);};
return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); };
const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); };
const markers = [...document.querySelectorAll('[class*="natTick"], [class*="NatLine"]')]
.filter(el => el.getBoundingClientRect().width > 0);
const out = { count: markers.length, failures: [], unmeasured: 0 };
for (const el of markers) {
/*
* Scroll it in first. elementsFromPoint takes VIEWPORT coordinates and
* returns an empty stack for anything off-screen — and these markers sit
* well down the page. Without this the backdrop silently defaults to
* white, which makes a teal-on-teal marker look like teal-on-white and
* pass. 'instant' because globals.css sets scroll-behavior: smooth on
* html, and a smooth scroll would still be moving when we measure.
*/
el.scrollIntoView({ block: 'center', behavior: 'instant' });
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const own = ps(getComputedStyle(el).backgroundColor);
const edge = ps((getComputedStyle(el).boxShadow.match(/rgba?\\([^)]*\\)/) || [])[0] || '');
if (!own) continue;
// What is genuinely underneath, by stacking order rather than by ancestry.
const stack = document.elementsFromPoint(cx, cy);
if (stack.length === 0) { out.unmeasured++; continue; } // never assume white
let backdrop = null;
for (const under of stack) {
if (under === el || el.contains(under)) continue;
const c = ps(getComputedStyle(under).backgroundColor);
if (c && c.a > 0.9) { backdrop = c; break; }
}
if (!backdrop) { out.unmeasured++; continue; }
// A knockout marker only needs ONE of its two parts to separate.
const best = Math.max(RT(ov(own, backdrop), backdrop),
edge ? RT(ov(edge, backdrop), backdrop) : 0);
if (best < 3) {
out.failures.push((el.className || '?').toString().split(' ')[0] +
' ' + best.toFixed(2) + ':1 over rgb(' +
[backdrop.r, backdrop.g, backdrop.b].map(Math.round).join(',') + ')');
}
}
return out;
})()`;
async function firstSchoolLink(page: Page, query: string): Promise<string> {
await page.goto(`/?search=${encodeURIComponent(query)}`);
const link = schoolLinks(page).first();
await expect(link).toBeVisible({ timeout: 15_000 });
return (await link.getAttribute('href'))!;
}
for (const scheme of ['light', 'dark'] as const) {
test(`chart benchmark markers stay visible in the ${scheme} theme`, async ({ browser }) => {
const context = await browser.newContext({ colorScheme: scheme });
const page = await context.newPage();
const failures: string[] = [];
// One primary (SATs bars) and one secondary (Attainment 8 bar) — the two
// templates carry separate implementations of the same marker.
for (const query of ['primary', 'academy']) {
const href = await firstSchoolLink(page, query);
await page.goto(href);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(900); // bar widths animate in from an effect
const res = (await page.evaluate(MARKER_PROBE)) as
{ count: number; failures: string[]; unmeasured: number };
test.skip(res.count === 0 && query === 'primary',
'no benchmark markers rendered — environment has no national averages');
// A marker we could not resolve a backdrop for is a hole in the check,
// not a pass. Fail loudly rather than quietly measuring nothing.
expect(res.unmeasured,
`${href}: ${res.unmeasured} of ${res.count} markers had no resolvable backdrop`).toBe(0);
failures.push(...res.failures.map(f => `${href} → ${f}`));
}
await context.close();
expect(failures, `benchmark markers under 3:1 in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
});
}
test('the brand asset set is complete and served', async ({ page }) => {
const response = await page.goto('/');
expect(response?.ok()).toBe(true);
@@ -918,21 +789,9 @@ test('the brand asset set is complete and served', async ({ page }) => {
expect(og.ok()).toBe(true);
expect(og.headers()['content-type']).toContain('image/png');
/*
* iOS ignores SVG touch icons, so this must be a real raster.
*
* Read the href off the page rather than hardcoding a path. The route moved
* when the generated app/apple-icon.tsx became a static app/apple-icon.png:
* a generated icon is served at /apple-icon, a static one at /apple-icon.png
* with a content hash. This test kept asking for /apple-icon and got a 404 —
* reporting a missing icon while the icon was present and correctly linked.
*/
const appleLink = page.locator('link[rel="apple-touch-icon"]');
await expect(appleLink).toHaveCount(1);
const appleHref = await appleLink.getAttribute('href');
expect(appleHref).toBeTruthy();
const apple = await page.request.get(appleHref!);
expect(apple.ok(), `apple touch icon at ${appleHref} should be served`).toBe(true);
// iOS ignores SVG touch icons, so this must be a real raster.
const apple = await page.request.get('/apple-icon');
expect(apple.ok()).toBe(true);
expect(apple.headers()['content-type']).toContain('image/png');
// Android needs a maskable PNG or the install prompt has no icon.
@@ -1053,22 +912,8 @@ test('the header carries the schoolcompare lockup', async ({ page }) => {
// hashed at build time and change on any unrelated edit.
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
/*
* The mark is raster artwork, not an inline SVG — it has been since the
* supplied logo replaced the reconstruction, and this locator went on
* looking for an <svg> that no longer exists.
*
* Assert on naturalWidth rather than visibility: a <picture> whose sources
* all 404 still lays out and still passes toBeVisible(), so the weaker
* assertion would go green on a broken lockup.
*/
const mark = home.locator('img').first();
const mark = home.locator('svg').first();
await expect(mark).toBeVisible();
const markState = await mark.evaluate((el: HTMLImageElement) => ({
loaded: el.complete && el.naturalWidth > 0,
src: el.currentSrc,
}));
expect(markState.loaded, `header mark failed to load: ${markState.src}`).toBe(true);
});
test('no visible text falls back to the browser default black', async ({ page }) => {
@@ -1210,635 +1055,3 @@ for (const scheme of ['light', 'dark'] as const) {
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
});
}
// ── Last distance offered ────────────────────────────────────────────────
//
// Coverage is partial by nature: only 57 local authorities publish cut-off
// distances at all, and staging may not yet hold the extract. These tests
// therefore locate a school that HAS a figure and assert the invariants that
// make it safe to show, rather than asserting any particular school has one.
/** URNs known to be in the collected cut-off dataset, spread across LAs so a
* single council's data gap cannot skip the whole check. */
const CUTOFF_CANDIDATE_URNS = [
139837, 100010, 100011, 100028, 100029, 130342, // Camden
100458, 135587, // Islington (banded)
101099, 100553, 102574, 100769, // mixed
];
async function schoolWithCutoff(page: Page) {
for (const urn of CUTOFF_CANDIDATE_URNS) {
const res = await page.request.get(`/api/schools/${urn}`);
if (!res.ok()) continue;
const body = await res.json();
if (body?.admission_distance?.distance_m == null) continue;
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
}
return null;
}
test('a published cut-off distance is shown with the year it belongs to', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
const label = page.getByText(/Last distance offered/).first();
await expect(label).toBeVisible();
// The year is the difference between a fact and a floating number: a cut-off
// is the outcome of one admissions round and means nothing without it.
await expect(label).toContainText(`September ${found!.distance.year}`);
// And the figure itself, in the unit councils publish in.
// No trailing \b: the tile's support figure is an adjacent text node, so
// the element reads "0.88 miles 1.4 km" and a word boundary after "miles"
// is not guaranteed. The leading shape is what matters — a decimal figure
// in miles, never a metric one.
await expect(page.getByText(/\d+\.\d+ miles/).first()).toBeVisible();
});
test('a cut-off distance is never shown without saying it is not a catchment', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/not a fixed catchment/)).toBeVisible();
// The old secondary template asserted "distance cut-off data is not
// available for this school" on every page, including ones whose council
// does publish it. Showing both at once would be a flat contradiction.
await expect(page.getByText(/has not published a cut-off distance/)).toHaveCount(0);
// A banded school's figure is the widest of several routes, and must say so.
if ((found!.distance.route_count ?? 1) > 1) {
await expect(page.getByText(/admission routes at this school/)).toBeVisible();
}
});
test('a cut-off distance opens the Admissions section in the sticky nav', async ({ page }) => {
// buildNavItems and the section composers gate on the same condition; when
// they drift the nav links to an anchor that was never rendered.
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('#admissions')).toHaveCount(1);
});
test('no school page shows an implausible cut-off distance', async ({ page }) => {
// The source carries transcription errors up to 533 miles, filtered in
// stg_school_distance. This asserts the filter is actually reaching the API,
// because a visibly absurd figure on a live page is the failure that would
// cost the site its credibility.
let checked = 0;
for (const urn of CUTOFF_CANDIDATE_URNS) {
const res = await page.request.get(`/api/schools/${urn}`);
if (!res.ok()) continue;
const d = (await res.json())?.admission_distance;
if (d?.distance_m == null) continue;
checked += 1;
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
.toBeGreaterThanOrEqual(25);
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
.toBeLessThanOrEqual(25000);
}
test.skip(checked === 0, 'no cut-off distances available to check yet');
});
// ── The Distance view, map and postcode check ────────────────────────────
test('the public API serves the latest cut-off only', async ({ page }) => {
/*
* Earlier years are held back as a paid feature. This endpoint is public and
* unauthenticated, so withholding them in the UI alone would be decoration:
* anyone could read the history out of the network tab. The mart still holds
* every year — this asserts what leaves the process, not what was collected.
*/
let checked = 0;
for (const urn of CUTOFF_CANDIDATE_URNS) {
const res = await page.request.get(`/api/schools/${urn}`);
if (!res.ok()) continue;
const body = await res.json();
if (body?.admission_distance?.distance_m == null) continue;
checked += 1;
expect(body, `URN ${urn} still exposes a cut-off history`)
.not.toHaveProperty('admission_distance_history');
}
test.skip(checked === 0, 'no cut-off distances available to check yet');
});
test('a school page shows no year-by-year cut-off record', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
// The render side of the same rule, so a future component cannot put the
// history back without the API.
await expect(page.getByText(/Last distance offered, by year/)).toHaveCount(0);
await expect(page.getByText(/too few to read as a trend/)).toHaveCount(0);
/*
* No "Distance" tab in the admissions segmented control.
*
* Scoped to the control, and exact, because getByRole matches accessible
* names by case-insensitive SUBSTRING: an unscoped { name: 'Distance' }
* matched "Show this distance on a map" — the map toggle added by this same
* feature — and failed a page that was entirely correct. Naming the group
* this assertion is about also means unrelated copy elsewhere on the page
* can never break it again.
*/
const viewToggle = page.getByRole('group', { name: 'Admissions view' });
const tabs = await viewToggle.getByRole('button').allTextContents();
// Read the tabs positively rather than asserting an absence against a
// locator that might resolve to nothing: if the group selector ever stops
// matching, an absence check passes for the wrong reason, which is how the
// bug this test is guarding would slip back in unnoticed.
expect(tabs, 'admissions view toggle did not resolve').toContain('This year');
expect(tabs.map((s) => s.trim()), `admissions tabs: ${tabs.join(', ')}`)
.not.toContain('Distance');
});
test('the postcode check answers for the published year, and names it', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.goto(`/school/${found!.urn}`);
const section = page.locator('#distance');
// Needs coordinates as well as a figure; not every school has both.
test.skip(await section.count() === 0, 'this school has no distance section');
await expect(section).toBeVisible({ timeout: 15_000 });
await page.getByLabel('Your postcode').fill('SW1A 1AA');
await page.getByRole('button', { name: 'Check', exact: true }).click();
const result = page.getByRole('status');
const error = page.getByRole('alert');
await expect(result.or(error)).toBeVisible({ timeout: 20_000 });
if (await result.isVisible()) {
// A verdict without its year is a number a parent cannot place.
await expect(result).toContainText(new RegExp(`September ${found!.distance.year}`));
await expect(result).toContainText(/inside|beyond|too close/);
// Both figures in the sentence must be in the same unit. The verdict used
// to render the home distance in metres and the cut-off in miles — "69 m
// away — inside the September 2026 cut-off of 0.17 miles" — which asks the
// reader to convert between units to check a comparison already made for
// them. Miles throughout: the unit councils publish and quote.
const headline = (await result.textContent()) ?? '';
const verdictLine = headline.split('.')[0];
expect(verdictLine, `verdict mixes units: ${verdictLine}`).not.toMatch(/\d\s?m\b/);
expect((verdictLine.match(/miles/g) ?? []).length,
`verdict should quote two figures in miles: ${verdictLine}`).toBe(2);
}
});
test('the postcode check states its limits before it is used', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.goto(`/school/${found!.urn}`);
const section = page.locator('#distance');
test.skip(await section.count() === 0, 'this school has no distance section');
await expect(section).toBeVisible({ timeout: 15_000 });
const caveat = page.getByText(/Distance is the last criterion applied/);
await expect(caveat).toBeVisible();
await expect(caveat).toContainText(/not a catchment boundary/);
await expect(caveat).toContainText(/walking route/);
await expect(caveat).toHaveCount(1);
});
test('the distance section never scrolls the page sideways', async ({ page }) => {
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has a published cut-off yet');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/school/${found!.urn}`);
const section = page.locator('#distance');
test.skip(await section.count() === 0, 'this school has no distance section');
await expect(section).toBeVisible({ timeout: 15_000 });
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1);
});
test('no single section dominates the height of a school page', async ({ page }) => {
/*
* The admissions views are stacked in one grid cell so switching them never
* shifts layout, which means the card is sized by its TALLEST view while the
* hidden ones keep their box. A distance view carrying a chart, a table and a
* map was added there and measured 1402px against the tile grid's 316px; the
* DEFAULT view rendered as four tiles adrift in ~1080px of blank card, and
* Admissions alone came to half the height of every section on the page
* (1503px against 526px for the next largest).
*
* Measuring the card's internals cannot catch it: the tile grid is
* `flex: 1`, so it absorbs the stretch and every box still looks full. What
* a reader actually sees is one section wildly out of proportion with its
* neighbours, so that is what this asserts.
*/
const found = await schoolWithCutoff(page);
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(1200); // charts settle, and they carry real height
const sections = await page.evaluate(() =>
[...document.querySelectorAll('section[id]')]
.map((s) => ({ id: s.id, h: Math.round(s.getBoundingClientRect().height) }))
.filter((s) => s.h > 0));
expect(sections.length).toBeGreaterThanOrEqual(3);
const heights = sections.map((s) => s.h).sort((a, b) => a - b);
const median = heights[Math.floor(heights.length / 2)];
const worst = sections.reduce((a, b) => (a.h > b.h ? a : b));
// Generous: a rich section may fairly run to twice a plain one. Nearly three
// times over is the shape of a layout fault, not of denser content.
expect(
worst.h / median,
`#${worst.id} is ${worst.h}px against a ${median}px median: `
+ sections.map((s) => `${s.id}=${s.h}`).join(', '),
).toBeLessThan(2.5);
});
/*
* England-only corpus.
*
* GIAS ships the whole UK plus overseas and offshore establishments, none of
* which carry comparable DfE performance data. dim_school and dim_location
* exclude them (vars.non_england_school_type_codes in dbt_project.yml), which
* keeps them out of the site, the filter lists and the sitemap together.
*
* These assert the published surface, not the warehouse: the dbt test
* assert_england_only_schools guards the marts, and these guard what the
* environment actually serves once the marts have been rebuilt.
*/
const WELSH_AUTHORITIES = [
'Blaenau Gwent', 'Bridgend', 'Caerphilly', 'Cardiff', 'Carmarthenshire',
'Ceredigion', 'Conwy', 'Denbighshire', 'Flintshire', 'Gwynedd',
'Isle of Anglesey', 'Merthyr Tydfil', 'Monmouthshire', 'Neath Port Talbot',
'Newport', 'Pembrokeshire', 'Powys', 'Rhondda Cynon Taf', 'Swansea',
'Torfaen', 'Vale of Glamorgan', 'Wrexham',
];
const NON_ENGLAND_AUTHORITIES = [
'BFPO Overseas Establishments', 'Fieldwork Overseas Establishments',
'Gibraltar Overseas Establishments', 'Guernsey Offshore Establishments',
'Isle of Man Offshore Establishments', 'Jersey Offshore Establishments',
'Scotland Offshore Establishments',
];
const NON_ENGLAND_TYPES = [
'Welsh establishment', 'Offshore schools',
"Service children's education", 'British schools overseas',
];
test('the local authority filter offers no Welsh or overseas authority', async ({ page }) => {
const res = await page.request.get('/api/filters');
expect(res.ok()).toBeTruthy();
const { local_authorities: las } = await res.json();
expect(Array.isArray(las)).toBeTruthy();
// Guards against the list being empty, which would pass the check below
// for the wrong reason.
expect(las.length).toBeGreaterThan(100);
const leaked = [...WELSH_AUTHORITIES, ...NON_ENGLAND_AUTHORITIES]
.filter((la) => las.includes(la));
expect(leaked, `non-England authorities still offered: ${leaked.join(', ')}`)
.toEqual([]);
});
test('the school type filter offers no non-England establishment type', async ({ page }) => {
const res = await page.request.get('/api/filters');
expect(res.ok()).toBeTruthy();
const { school_types: types } = await res.json();
expect(Array.isArray(types)).toBeTruthy();
expect(types.length).toBeGreaterThan(10);
const leaked = NON_ENGLAND_TYPES.filter((t) => types.includes(t));
expect(leaked, `non-England types still offered: ${leaked.join(', ')}`)
.toEqual([]);
});
test('searching a Welsh authority by name returns no schools', async ({ page }) => {
// Cardiff held 144 Welsh establishments and nothing else, so the authority
// should now be absent from the corpus entirely rather than merely thinned.
const res = await page.request.get('/api/schools?local_authority=Cardiff&page_size=1');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.total ?? (body.schools ?? []).length).toBe(0);
});
test('a Welsh school GIAS does not type as Welsh is gone too', async ({ page }) => {
// Beechwood College, Sully, CF64 5SE. GIAS types it "Special post 16
// institution" (32), not "Welsh establishment" (30), so the establishment
// type filter alone left it behind — it was the last Welsh school standing.
const res = await page.request.get('/api/schools?local_authority=Vale%20of%20Glamorgan&page_size=1');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.total ?? (body.schools ?? []).length).toBe(0);
const page404 = await page.goto('/school/142458-beechwood-college');
expect(page404?.status()).toBe(404);
});
test('English schools with Welsh postcodes are kept', async ({ page }) => {
// Royal Mail postcode areas straddle the border: these Gloucestershire
// schools carry NP16/NP25 postcodes but are English schools with English
// data. A postcode-based filter would have wrongly deleted them, so this
// guards the fix from being "simplified" into one.
for (const urn of [115639, 115655, 137382]) {
const res = await page.request.get(`/api/schools/${urn}`);
expect(res.ok(), `URN ${urn} should still resolve`).toBeTruthy();
const { school_info } = await res.json();
expect(school_info.local_authority).toBe('Gloucestershire');
}
});
test('a Welsh school URL 404s while an English one still resolves', async ({ page }) => {
// Paired on purpose: the Welsh assertion alone would also pass if the whole
// site were down, which is the failure this test most needs to distinguish.
const english = await page.request.get('/api/schools?search=primary&per_page=1');
expect(english.ok()).toBeTruthy();
const [first] = (await english.json()).schools ?? [];
expect(first, 'no English school available to compare against').toBeTruthy();
const good = await page.goto(`/school/${first.urn}-x`);
expect(good?.status(), 'an English school should still resolve').toBeLessThan(400);
// Adamsdown Primary School, Cardiff — a Welsh establishment (URN 401559).
const welsh = await page.goto('/school/401559-adamsdown-primary-school');
expect(welsh?.status(), 'a Welsh school should no longer resolve').toBe(404);
});
async function sitemapChildren(page: Page): Promise<string[]> {
const res = await page.request.get('/sitemap.xml');
expect(res.ok()).toBeTruthy();
const index = await res.text();
expect(index).toContain('<sitemapindex');
return [...index.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
}
test('the sitemap index names children that all resolve', async ({ page }) => {
const index = await (await page.request.get('/sitemap.xml')).text();
// An index holds <sitemap> entries only; mixing in <url> is invalid.
expect(index).not.toContain('<url>');
const locs = await sitemapChildren(page);
expect(locs.length).toBeGreaterThanOrEqual(2);
for (const loc of locs) {
expect(loc.startsWith('https://www.schoolcompare.co.uk/sitemaps/')).toBeTruthy();
const child = await page.request.get(new URL(loc).pathname);
expect(child.ok(), `${loc} should resolve`).toBeTruthy();
expect(await child.text()).toContain('<urlset');
}
});
test('the sitemap submits no Welsh or overseas school', async ({ page }) => {
const locs = await sitemapChildren(page);
let total = 0;
for (const loc of locs) {
const xml = await (await page.request.get(new URL(loc).pathname)).text();
total += (xml.match(/<url>/g) ?? []).length;
// 401559 (Adamsdown, Cardiff) and 402426 (ACT Schools, Cardiff) were both
// submitted before the England-only filter landed.
expect(xml).not.toContain('/school/401559');
expect(xml).not.toContain('/school/402426');
}
expect(total, 'sitemap looks empty or truncated').toBeGreaterThan(1000);
});
test('the sitemap invents no priority or changefreq', async ({ page }) => {
const [first] = await sitemapChildren(page);
expect(first).toBeTruthy();
const xml = await (await page.request.get(new URL(first).pathname)).text();
// Google ignores both. They were noise dressed as signal.
expect(xml).not.toContain('<priority>');
expect(xml).not.toContain('<changefreq>');
});
/*
* Canonical URLs (spec 2026-08-20, W1).
*
* Every indexable route declares exactly one canonical, on the www host, with
* no query string. The homepage's eleven search params filter a result set
* rather than making a new document, so they all collapse onto "/".
*/
const CANONICAL_ROUTES: Array<[string, string]> = [
['/', 'https://www.schoolcompare.co.uk/'],
['/rankings', 'https://www.schoolcompare.co.uk/rankings'],
['/admissions', 'https://www.schoolcompare.co.uk/admissions'],
];
for (const [path, expected] of CANONICAL_ROUTES) {
test(`${path} declares exactly one canonical, on the www host`, async ({ page }) => {
await page.goto(path);
const hrefs = await page.locator('link[rel="canonical"]').evaluateAll(
(els) => els.map((e) => e.getAttribute('href')));
expect(hrefs, `${path} should declare one canonical`).toHaveLength(1);
expect(hrefs[0]).toBe(expected);
});
}
test('a filtered homepage still canonicalises to the bare root', async ({ page }) => {
await page.goto('/?search=primary&phase=primary&sort=name&page=2');
const href = await page.locator('link[rel="canonical"]').first()
.getAttribute('href');
expect(href).toBe('https://www.schoolcompare.co.uk/');
});
test('a school page canonicalises to its own slug on the www host', async ({ page }) => {
const res = await page.request.get('/api/schools?search=primary&per_page=1');
expect(res.ok()).toBeTruthy();
const [first] = (await res.json()).schools ?? [];
expect(first, 'no school available').toBeTruthy();
await page.goto(`/school/${first.urn}-x`);
const href = await page.locator('link[rel="canonical"]').first()
.getAttribute('href');
expect(href).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\/school\/\d+-/);
});
test('a bare /compare is indexable, a parameterised one is not', async ({ page }) => {
await page.goto('/compare');
await expect(page.locator('meta[name="robots"]')).toHaveCount(0);
const [a, b] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${a},${b}`);
const robots = await page.locator('meta[name="robots"]').first()
.getAttribute('content');
expect(robots).toContain('noindex');
expect(robots).toContain('follow');
// noindex but follow: the links out to each school page still count, so the
// canonical must still be present and point at the bare path.
const canonical = await page.locator('link[rel="canonical"]').first()
.getAttribute('href');
expect(canonical).toBe('https://www.schoolcompare.co.uk/compare');
});
/*
* Staging must not be indexable (spec 2026-08-20, W1 hygiene).
*
* These journeys only ever run against staging — deploy.yml passes
* STAGING_BASE_URL, and promote.yml only smoke-polls production without
* Playwright — so asserting the noindex header here is safe.
*/
test('staging answers noindex, and stays crawlable so the noindex is seen', async ({ page }) => {
const res = await page.request.get('/');
expect(res.ok()).toBeTruthy();
const tag = res.headers()['x-robots-tag'];
expect(tag, 'staging must send X-Robots-Tag').toBeTruthy();
expect(tag).toContain('noindex');
// The other half, and the reason this is one test rather than two: a
// Disallow would stop Google fetching the page at all, so it would never
// see the noindex above. The two only work together.
const robots = await (await page.request.get('/robots.txt')).text();
expect(robots).not.toMatch(/^\s*Disallow:\s*\/\s*$/mi);
});
test('a school page on staging is noindexed too, not just the homepage', async ({ page }) => {
const list = await page.request.get('/api/schools?search=primary&per_page=1');
const [first] = (await list.json()).schools ?? [];
expect(first, 'no school available').toBeTruthy();
const res = await page.request.get(`/school/${first.urn}-x`);
expect(res.headers()['x-robots-tag']).toContain('noindex');
});
/*
* W8 — the C1 pages must ship a description, and it must differentiate.
*
* Baseline was 0.43% CTR at position 6.1 on "compare school performance",
* against 9.16% for the brand query from the same neighbourhood. The SERP is
* owned by the DfE's own service, so a description that paraphrases it earns
* nothing. Google may rewrite a snippet, but it cannot use one we never sent.
*/
test('every C1 page ships a description, and none opens its title with the brand', async ({ page }) => {
for (const path of ['/', '/compare', '/rankings', '/admissions']) {
await page.goto(path);
const desc = await page.locator('meta[name="description"]').first()
.getAttribute('content');
expect(desc, `${path} must ship a description`).toBeTruthy();
expect(desc!.length, `${path} description too short to be worth reading`)
.toBeGreaterThan(100);
const title = await page.title();
expect(title.toLowerCase().startsWith('schoolcompare'),
`${path} spends its most valuable pixels on the brand`).toBe(false);
}
});
test('the homepage snippet names what gov.uk does not publish', async ({ page }) => {
await page.goto('/');
const desc = await page.locator('meta[name="description"]').first()
.getAttribute('content');
// Admissions distance is the one fact the DfE service has no equivalent for.
expect(desc).toMatch(/close you had to live|distance/i);
});
/*
* The location layer (spec 2026-08-21, W2).
*
* Location intent sat at position 49.5 with one click across the whole 16-month
* baseline — the site published no page about a place. These assert the four
* families render, stay in their own namespaces, and reach the sitemap.
*/
async function firstPlaceOfKind(page: Page, kind: string) {
const res = await page.request.get('/api/places');
expect(res.ok()).toBeTruthy();
const { places } = await res.json();
const hit = places.find((p: { kind: string }) => p.kind === kind);
expect(hit, `no ${kind} in the registry`).toBeTruthy();
return hit as { kind: string; slug: string; name: string; count: number };
}
for (const [kind, prefix, article] of [
['town', '/schools/', 'a'],
['authority', '/schools/authority/', 'an'],
['outcode', '/schools/near/', 'an'],
] as const) {
test(`${article} ${kind} page renders with its school count`, async ({ page }) => {
const place = await firstPlaceOfKind(page, kind);
await page.goto(`${prefix}${place.slug}`);
await expect(page.locator('h1')).toContainText(place.name, { ignoreCase: true });
await expect(page.locator('a[href^="/school/"]').first()).toBeVisible();
});
}
test('a town and an authority sharing a name are different pages', async ({ page }) => {
// 67 real collisions, and the authority is the larger set in only 43 — so
// one namespace would have published near-duplicates.
const { places } = await (await page.request.get('/api/places')).json();
const townSlugs = new Set(
places.filter((p: { kind: string }) => p.kind === 'town')
.map((p: { slug: string }) => p.slug));
const clash = places.find((p: { kind: string; slug: string }) =>
p.kind === 'authority' && townSlugs.has(p.slug));
test.skip(!clash, 'no town/authority name collision in this environment');
const townRes = await page.request.get(`/api/places/town/${clash.slug}`);
const laRes = await page.request.get(`/api/places/authority/${clash.slug}`);
expect(townRes.ok() && laRes.ok()).toBeTruthy();
const townUrns = (await townRes.json()).schools.map((s: { urn: number }) => s.urn).sort();
const laUrns = (await laRes.json()).schools.map((s: { urn: number }) => s.urn).sort();
expect(townUrns).not.toEqual(laUrns);
});
test('a place below the threshold has no page', async ({ page }) => {
// Crosby holds one school; publishing it would be a page with nothing to say.
const res = await page.request.get('/api/places/town/crosby');
expect(res.status()).toBe(404);
});
test('place pages declare a canonical and reach the sitemap', async ({ page }) => {
const place = await firstPlaceOfKind(page, 'town');
await page.goto(`/schools/${place.slug}`);
const canonical = await page.locator('link[rel="canonical"]').first()
.getAttribute('href');
expect(canonical).toBe(`https://www.schoolcompare.co.uk/schools/${place.slug}`);
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
expect(xml).toContain(`/schools/${place.slug}`);
});
test('the place page ships ItemList structured data that parses', async ({ page }) => {
const place = await firstPlaceOfKind(page, 'town');
await page.goto(`/schools/${place.slug}`);
const raw = await page.locator('script[type="application/ld+json"]').first()
.textContent();
const parsed = JSON.parse(raw!);
const types = (parsed['@graph'] ?? []).map((n: { '@type': string }) => n['@type']);
expect(types).toContain('ItemList');
expect(types).toContain('BreadcrumbList');
});
test('a place page states the local average against England', async ({ page }) => {
// The one number a list cannot give, and the reason these pages are not
// a name dropped into a template.
const place = await firstPlaceOfKind(page, 'town');
await page.goto(`/schools/${place.slug}`);
await expect(page.getByTestId('local-vs-england')).toContainText(/across England/i);
});
-591
View File
@@ -1,591 +0,0 @@
<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>
-130
View File
@@ -1,130 +0,0 @@
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/);
}
});
});
@@ -1,30 +0,0 @@
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).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('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);
});
});
@@ -1,152 +0,0 @@
/**
* 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/);
});
});
@@ -1,93 +0,0 @@
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' },
schools: [
{ urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
ofsted_grade: 1 } as never,
{ urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
ofsted_grade: 3 } 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\//);
}
});
});
@@ -1,191 +0,0 @@
/**
* 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();
});
});
@@ -1,182 +0,0 @@
/**
* 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');
});
});
@@ -60,7 +60,7 @@ describe('buildNavItems', () => {
it('omits sections with no data', () => {
const flags = computeSchoolFlags(specialFixture);
const ids = buildNavItems(flags, {
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
ofsted: null, admissions: null, yearlyDataLength: 1,
}).map((n) => n.id);
expect(ids).not.toContain('ofsted');
@@ -74,7 +74,6 @@ describe('buildNavItems', () => {
return buildNavItems(flags, {
ofsted: fixture.ofsted,
admissions: fixture.admissions,
admissionDistance: null,
yearlyDataLength: fixture.yearlyData.length,
}).find((n) => n.id === 'results')?.label;
};
@@ -84,27 +83,11 @@ describe('buildNavItems', () => {
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);
@@ -144,29 +127,16 @@ describe('buildSecondaryNavItems', () => {
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,
ofsted: null, admissions: null, yearlyDataLength: 1,
}).map((n) => n.id);
expect(ids).not.toContain('history');
-27
View File
@@ -1,27 +0,0 @@
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/');
});
});
@@ -31,7 +31,6 @@ export function renderSchoolDetail(fixture: any) {
const navItems = buildNavItems(flags, {
ofsted: fixture.ofsted,
admissions: fixture.admissions,
admissionDistance: fixture.admissionDistance ?? null,
yearlyDataLength: fixture.yearlyData.length,
});
@@ -45,7 +44,6 @@ export function renderSchoolDetail(fixture: any) {
>
<PrimarySchoolSections
{...fixture}
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
nationalAvg={nationalAveragesFixture}
flags={flags}
/>
@@ -59,7 +57,6 @@ export function renderSecondarySchoolDetail(fixture: any) {
const navItems = buildSecondaryNavItems(flags, {
ofsted: fixture.ofsted,
admissions: fixture.admissions,
admissionDistance: fixture.admissionDistance ?? null,
yearlyDataLength: fixture.yearlyData.length,
});
@@ -73,8 +70,6 @@ export function renderSecondarySchoolDetail(fixture: any) {
>
<SecondarySchoolSections
{...fixture}
admissionsHistory={fixture.admissionsHistory ?? []}
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
nationalAvg={nationalAveragesFixture}
flags={flags}
/>
+2 -6
View File
@@ -1,16 +1,12 @@
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
import { AdmissionsView } from '@/components/AdmissionsView';
export const dynamic = 'force-static';
export const metadata: Metadata = {
// 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' },
title: 'School Admissions Guide',
description:
'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') },
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
};
export default function AdmissionsPage() {
+7 -31
View File
@@ -5,7 +5,6 @@
import { fetchComparison, fetchMetrics } from '@/lib/api';
import { ComparisonView } from '@/components/ComparisonView';
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
interface ComparePageProps {
@@ -15,36 +14,13 @@ interface ComparePageProps {
}>;
}
/**
* 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 } };
}
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',
};
// Dynamic via searchParams; remove force-dynamic so internal data fetches
// can still use Next.js's per-call revalidate cache.
+7 -11
View File
@@ -193,17 +193,13 @@
drawn SVG, so its colours are in the file, not here. What is left is the
ground the artwork sits on and fades into.
--hero-ground is sampled from the artwork's own copy area, not from
Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
seam straight down the hero.
The artwork's copy area is not one flat colour: it runs from a peach
#FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
the left edge is foliage rather than cream. This value is sampled from
the middle of that pale run, where the headline and search actually sit;
the scrim is what covers the foliage further down. */
--hero-ground: #FEF2E1;
--hero-ground-rgb: 254, 242, 225;
--hero-ground is sampled from the artwork's own copy area (#FDF9F3),
not from Sand. The artwork reserves an empty cream region on the left
for the headline, and the scrim that guarantees text contrast fades from
this colour into it — a scrim in Sand (#F5EFE6, eight to thirteen points
darker per channel) leaves a visible seam straight down the hero. */
--hero-ground: #FDF9F3;
--hero-ground-rgb: 253, 249, 243;
/* ── Geometry & motion ──────────────────────────────────────────────
"Soft shapes, rounded corners" — the guideline's geometry is markedly
+8 -12
View File
@@ -5,7 +5,6 @@ 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';
// Manrope carries headings and key messaging — the guideline's "friendly,
@@ -48,32 +47,29 @@ export const metadata: Metadata = {
statusBarStyle: 'default',
},
title: {
default: 'Compare Schools Side by Side | schoolcompare',
default: 'schoolcompare | Compare School Performance',
template: '%s | 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.',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
authors: [{ name: 'schoolcompare' }],
manifest: '/manifest.json',
// 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),
metadataBase: new URL('https://schoolcompare.co.uk'),
openGraph: {
type: 'website',
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,
title: 'schoolcompare | Compare School Performance',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
url: 'https://schoolcompare.co.uk',
siteName: 'schoolcompare',
},
twitter: {
// 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.',
title: 'schoolcompare | Compare School Performance',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
},
};
+2 -20
View File
@@ -3,7 +3,6 @@
* 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';
@@ -34,25 +33,8 @@ interface HomePageProps {
* 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('/') },
title: { absolute: 'schoolcompare | Compare every school in England' },
description: 'Search and compare school performance across England',
};
// The page reads searchParams, which makes rendering dynamic by default.
+2 -9
View File
@@ -5,7 +5,6 @@
import { fetchRankings, fetchFilters, fetchMetrics } from '@/lib/api';
import { RankingsView } from '@/components/RankingsView';
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
interface RankingsPageProps {
@@ -18,15 +17,9 @@ interface RankingsPageProps {
}
export const metadata: Metadata = {
// '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.',
title: 'School Rankings',
description: 'Top-ranked schools by SATs and GCSE performance across England',
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
// 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
+1 -2
View File
@@ -4,7 +4,6 @@
*/
import { MetadataRoute } from 'next';
import { absoluteUrl } from '@/lib/site';
export default function robots(): MetadataRoute.Robots {
return {
@@ -15,6 +14,6 @@ export default function robots(): MetadataRoute.Robots {
disallow: ['/api/', '/_next/'],
},
],
sitemap: absoluteUrl('/sitemap.xml'),
sitemap: 'https://schoolcompare.co.uk/sitemap.xml',
};
}
+3 -9
View File
@@ -15,7 +15,6 @@ import {
} 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';
/**
@@ -98,7 +97,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
title,
description,
type: 'website',
url: absoluteUrl(canonicalPath),
url: `https://schoolcompare.co.uk${canonicalPath}`,
siteName: 'schoolcompare',
},
twitter: {
@@ -107,7 +106,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
description,
},
alternates: {
canonical: absoluteUrl(canonicalPath),
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
},
};
} catch {
@@ -148,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -177,8 +176,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
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);
@@ -231,8 +228,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
@@ -254,7 +249,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
@@ -1,61 +0,0 @@
/**
* 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: `${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={[]} />;
}
-91
View File
@@ -1,91 +0,0 @@
/**
* 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 {
title: `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)}
/>
);
}
@@ -1,67 +0,0 @@
/**
* 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: `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={[]}
/>
);
}
@@ -1,62 +0,0 @@
/**
* 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: `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={[]}
/>
);
}
+26 -2
View File
@@ -1,8 +1,32 @@
import { proxySitemap } from '@/lib/sitemapProxy';
/**
* 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';
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() {
return proxySitemap('/sitemap.xml');
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' },
});
}
@@ -1,24 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -460,30 +460,6 @@
}
}
/*
* 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 {
-16
View File
@@ -92,22 +92,6 @@
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-family: var(--font-display);
-8
View File
@@ -35,14 +35,6 @@ export function Footer() {
<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}
+7 -84
View File
@@ -161,22 +161,10 @@
* page and out-shouts the H1 and the search box. Dimming it in CSS is the
* whole treatment, and the scrim then fades it into the dark panel instead of
* into cream.
*
* Raising the brightness raises the background under light dark-theme text, so
* this value has a contrast floor, not just a taste range. Measured off
* rendered pixels, sampling background up to 120px past each line:
*
* brightness title body
* 0.52 11.01:1 6.44:1
* 0.75 9.48:1 5.21:1 ← current
*
* Body text is the binding one. Going much above 0.75 walks it toward the
* 4.5:1 floor, and at that point the scrim needs to carry further right rather
* than the artwork being dimmed less.
*/
@media (prefers-color-scheme: dark) {
.heroArt img {
filter: brightness(0.75) saturate(0.72) contrast(1.02);
filter: brightness(0.52) saturate(0.72) contrast(1.02);
}
.heroPanel::before {
background: linear-gradient(
@@ -189,43 +177,6 @@
}
}
/*
* The one line in the first person, so it is set apart from the marketing copy
* around it without shouting: display face, a step down in size, and a short
* brand rule instead of a bullet or an emoji.
*
* No italic — Manrope ships no italic in the loaded weights, so font-style
* would be synthesised into a slant. Same reason .heroEmph sets font-style
* back to normal.
*/
.heroByline {
display: flex;
align-items: center;
gap: 0.55rem;
margin: 1.25rem 0 0;
font-family: var(--font-display);
font-size: var(--step--1);
font-weight: 500;
font-style: normal;
color: var(--text-secondary);
}
.heroByline::before {
content: '';
flex: 0 0 auto;
width: 1.25rem;
height: 2px;
border-radius: 1px;
background: var(--brand);
}
@media (max-width: 640px) {
.heroByline {
margin-top: 1rem;
font-size: var(--step--2);
}
}
.heroEyebrow {
display: inline-flex;
align-items: center;
@@ -302,42 +253,18 @@
* the same decision.
*/
@media (max-width: 860px) {
/*
* Artwork above the copy, not below it.
*
* The DOM keeps .heroContent first so the desktop overlay does not depend on
* source order, which left the band stranded under the search on phones —
* reading as a strip stuck on the end rather than as a hero image. `order`
* moves it visually only; it is decorative and aria-hidden, so there is no
* reading order to disturb.
*
* It costs about 136px above the search. Measured on a 667px viewport (the
* shortest phone still in use) the search still lands near 400px, well
* inside the fold.
*/
.heroPanel {
display: flex;
flex-direction: column;
}
.heroContent {
padding: 1.75rem 1.5rem 1.75rem;
padding: 2rem 1.5rem 1.75rem;
max-width: none;
}
/* 13rem, not 11: at 860px an 11rem band is 4.5:1, and squeezing the 3.2:1
crop into that throws away enough height to cut the flag off the roof and
the base off the building. 13rem holds it at about 3.8:1, where the
building stays whole. */
.heroArt {
position: static;
order: -1;
height: 13rem;
height: 11rem;
}
/* The band crop puts the schoolhouse at 73% across — reported by
scripts/build-hero-images.js, which derives it from the crop box rather
than leaving the two to drift. Squeezing narrower then crops the emptier
left side rather than the subject. */
/* The band crop centres the schoolhouse at about 65% across, so squeezing
toward 4.9:1 crops the empty sides rather than the subject. */
.heroArt img {
object-position: 73% center;
object-position: 65% center;
}
/* The scrim exists to protect text sitting on the artwork. Nothing sits on
it here, and leaving it would wash the band out. */
@@ -378,12 +305,8 @@
font-size: 1.75rem;
margin-bottom: 0.5rem;
}
/* 10rem, not 8.5: the band is widest-per-height right at this breakpoint —
at 640px an 8.5rem band is 4.2:1, worse than anything above it, because
the height steps down here while the width does not. 10rem keeps it near
3.6:1, in line with the rest of the range. */
.heroArt {
height: 10rem;
height: 8.5rem;
}
}
+2 -14
View File
@@ -157,20 +157,7 @@ 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',
title: 'Official & trusted',
body: 'Every figure comes from DfE performance tables and Ofsted.',
},
{
@@ -463,6 +450,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
geoState={geoState}
geoError={geoError}
/>
</div>
{/*
+8 -8
View File
@@ -40,8 +40,8 @@
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;
const WIDE_W = 1672;
const WIDE_H = 941;
export function HeroIllustration() {
return (
@@ -51,13 +51,13 @@ export function HeroIllustration() {
<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"
srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w"
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"
srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w"
sizes={SIZES}
/>
{/*
@@ -71,15 +71,15 @@ export function HeroIllustration() {
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 media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" />
<source
type="image/avif"
srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
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"
srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w"
sizes={SIZES}
/>
{/*
@@ -90,7 +90,7 @@ export function HeroIllustration() {
*/}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/brand/hero-wide-1400.jpg"
src="/brand/hero-wide-1200.jpg"
alt=""
aria-hidden="true"
width={WIDE_W}
@@ -1,169 +0,0 @@
/**
* 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: '&copy; <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%' }} />;
}
+7 -39
View File
@@ -48,32 +48,13 @@
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: 3px;
width: 2px;
transform: translateX(-50%);
background: var(--bg-card);
box-shadow: 0 0 0 1px var(--text-primary);
background: var(--brand);
border-radius: 2px;
z-index: 4;
pointer-events: none;
@@ -82,14 +63,13 @@
.natTick::before {
content: '';
position: absolute;
top: -4px;
top: -3px;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 6px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--bg-card);
box-shadow: 0 0 0 1px var(--text-primary);
background: var(--brand);
}
.barHeaderRight {
@@ -142,24 +122,12 @@
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(--brand);
}
.barExceeding {
background: var(--brand-stronger);
background: var(--brand);
}
.barLabel {
+3 -26
View File
@@ -143,40 +143,17 @@ 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(--brand)' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
Expected standard
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--brand-stronger)' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
Exceeding / high score
</div>
<div className={styles.legendItem}>
<div
className={styles.legendSwatch}
style={{
background: 'var(--bg-card)',
boxShadow: '0 0 0 1px var(--text-primary)',
width: '3px',
height: '12px',
borderRadius: '2px',
}}
/>
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} />
National average
</div>
</div>
@@ -1,108 +0,0 @@
/* Tokens only — see globals.css. Matches RankingsView's conventions. */
.container {
width: 100%;
min-width: 0;
}
.header {
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 2.25rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
font-family: var(--font-display);
text-wrap: balance;
}
.summary {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.6;
}
/* The one number a list cannot give you, so it gets its own band. */
.compare {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.875rem 1.125rem;
margin: 0 0 1.5rem;
color: var(--text-primary);
font-size: 1rem;
}
.ofsted {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
list-style: none;
padding: 0;
margin: 0 0 1.5rem;
font-size: 0.9375rem;
color: var(--text-secondary);
}
/* Wide content scrolls in its own container so the page body never does. */
.tableWrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
background: var(--bg-secondary);
color: var(--text-secondary);
font-weight: 600;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table th:last-child,
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.neighbours {
margin-top: 2rem;
}
.neighbours h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.75rem;
font-family: var(--font-display);
}
.neighbours ul {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
list-style: none;
padding: 0;
margin: 0;
}
-144
View File
@@ -1,144 +0,0 @@
/**
* One place page, shared by all four families.
*
* They differ in what fills the registry, not in what the page shows, so a
* second component would be a second place to forget the same change.
*
* The local-versus-England comparison is the reason this page is not a list:
* it is the one number a parent cannot get by reading the schools one by one,
* and it is what keeps the page from reading as a name dropped into a
* template.
*/
import Link from 'next/link';
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
import { placeUrl, authoritySlug } from '@/lib/places';
import { schoolUrl } from '@/lib/utils';
import { absoluteUrl } from '@/lib/site';
import styles from './PlaceView.module.css';
interface Props {
detail: PlaceDetail;
phase?: 'primary' | 'secondary';
englandAverage: number | null;
/** Nearby places, so the page links onward instead of dead-ending. */
neighbours: PlaceSummary[];
}
// Ofsted grades in the order they are reported.
const OFSTED_LABELS: Array<[number, string]> = [
[1, 'Outstanding'], [2, 'Good'],
[3, 'Requires improvement'], [4, 'Inadequate'],
];
export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) {
const { place, schools, averages } = detail;
const metric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
const local = averages[metric];
const phaseWord = phase === 'secondary' ? 'Secondary schools'
: phase === 'primary' ? 'Primary schools' : 'Schools';
const graded = OFSTED_LABELS
.map(([grade, label]) => [label, schools.filter((s) => s.ofsted_grade === grade).length] as const)
.filter(([, n]) => n > 0);
// ItemList tells Google this page is a ranked set rather than prose;
// BreadcrumbList puts the place in a hierarchy. Capped at 20 because that
// is what the page shows above the fold and what the markup should mirror.
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'ItemList',
name: `${phaseWord} in ${place.name}`,
numberOfItems: schools.length,
itemListElement: schools.slice(0, 20).map((s, i) => ({
'@type': 'ListItem',
position: i + 1,
url: absoluteUrl(schoolUrl(s.urn, s.school_name)),
name: s.school_name,
})),
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Schools',
item: absoluteUrl('/') },
{ '@type': 'ListItem', position: 2, name: place.name },
],
},
],
};
return (
<div className={styles.container}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<header className={styles.header}>
<h1>{phaseWord} in {place.name}</h1>
<p className={styles.summary}>
{place.count} schools
{place.parent_authority && (
<>
{' · '}
<Link href={`/schools/authority/${authoritySlug(place.parent_authority)}`}>
{place.parent_authority}
</Link>
</>
)}
</p>
</header>
{local != null && englandAverage != null && (
<p className={styles.compare} data-testid="local-vs-england">
{place.name} averages <strong>{Math.round(local)}</strong> against{' '}
<strong>{Math.round(englandAverage)}</strong> across England.
</p>
)}
{graded.length > 0 && (
<ul className={styles.ofsted} data-testid="ofsted-distribution">
{graded.map(([label, n]) => (
<li key={label}>{label}: <strong>{n}</strong></li>
))}
</ul>
)}
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>School</th>
<th>{phase === 'secondary' ? 'Attainment 8' : 'RWM expected'}</th>
</tr>
</thead>
<tbody>
{schools.map((s) => (
<tr key={s.urn}>
<td>
<Link href={schoolUrl(s.urn, s.school_name)}>{s.school_name}</Link>
</td>
<td className={styles.num}>
{s[metric] == null ? '—' : Math.round(Number(s[metric]))}
</td>
</tr>
))}
</tbody>
</table>
</div>
{neighbours.length > 0 && (
<nav className={styles.neighbours} aria-label="Nearby places">
<h2>Nearby</h2>
<ul>
{neighbours.map((n) => (
<li key={n.kind + n.slug}>
<Link href={placeUrl(n.kind, n.slug)}>{n.name}</Link>
</li>
))}
</ul>
</nav>
)}
</div>
);
}
@@ -9,128 +9,62 @@
* AdmissionsViewToggle client island, which receives both views as
* server-rendered children. When there is only one year of offer data no
* toggle renders at all, so such pages ship zero admissions JavaScript.
*
* The two views are stacked in one grid cell so switching never shifts layout,
* which means the card is sized by the TALLER of them. Keep any view added
* here close to the tile grid in height: a cut-off-distance view carrying a
* chart, a table and a map was tried, measured 1402px against the tiles' 316px,
* and left the default view as four tiles adrift in blank card. It now lives in
* DistanceSection.
*/
import type { ReactNode } from 'react';
import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import type { SchoolAdmissions } from '@/lib/types';
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
import { summariseAdmissions } from '@/lib/compareLogic';
import { Section, sectionStyles as styles } from './sectionShared';
import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
import { AdmissionsTrendChart } from './charts';
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
export function AdmissionsSection({
admissions,
admissionsHistory,
admissionDistance,
isAllThrough,
}: {
/* Nullable: the two data sources are independent. A minority of schools have
a published cut-off distance but no EES admissions row (3% of the schools
that render, sampled against staging), and for those this section exists
to carry the distance alone. */
admissions: SchoolAdmissions | null;
admissions: SchoolAdmissions;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
isAllThrough: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
// Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2;
const showAdmissionsTrend = admissionsOfferYears >= 2;
// Banded interpretation of the first-choice offer rate ("More than half of
// first choices missed out" etc.) — the same banding the compare screen
// uses, so a low offer rate reads as how severe it actually is.
const admissionsSummary = admissions ? summariseAdmissions(admissions) : null;
const admissionsSummary = summariseAdmissions(admissions);
const title = (
<>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`}</>
);
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>;
{/* All-through admissions data covers a single entry point (usually the
Year 7 secondary intake), not reception — say so, or a parent could
read these as the whole-school figures. */}
const subtitle: ReactNode = isAllThrough && admissions?.school_phase ? (
const subtitle: ReactNode = isAllThrough && admissions.school_phase ? (
<p className={styles.sectionSubtitle}>
These figures are for {admissions.school_phase.toLowerCase()} entry
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
</p>
) : null;
{/* Spans both columns rather than taking a half-width cell. This is the
figure parents come to the page for, and at tile width the two-line
"0.31 miles / September 2025" pairing wraps badly. */}
{/* The {' '} between the figure and its metric support is not decoration.
The two are flex children, so the gap is drawn by CSS and the text layer
had nothing between them: textContent read "0.88 miles1.4 km", which is
what a screen reader announces and what any text matcher sees. Whitespace
text nodes are not rendered as flex items, so this changes the reading
without changing the layout. */}
const distanceTile = cutoff && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
<dd className={styles.admissionsTileNum}>
{cutoff.primary}{' '}
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
</dd>
<dt className={styles.admissionsTileLabel}>
Last distance offered · {cutoff.entryYear}
</dt>
</div>
);
const distanceNote = cutoff && (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
{cutoff.routeNote && <> {cutoff.routeNote}</>}
</p>
);
/*
* Which row template the tile grid needs.
*
* The grid reserves two equal rows so the year view matches the height of the
* trend chart beside it. With a cut-off and no EES admissions there are no
* metric tiles to fill them, and the reserved rows render as a bare block of
* the grid's own gap colour under the distance tile.
*/
const hasMetricTiles = admissions != null && (
admissions.places_offered != null
|| admissions.first_preference_applications != null
|| admissions.first_preference_offer_pct != null
|| admissions.total_applications != null
);
const tilesClass = [
styles.admissionsTiles,
cutoff && hasMetricTiles ? styles.admissionsTilesWithDistance : '',
cutoff && !hasMetricTiles ? styles.admissionsTilesDistanceOnly : '',
].filter(Boolean).join(' ');
const yearView = (
<>
<dl className={tilesClass}>
{admissions?.places_offered != null && (
<dl className={styles.admissionsTiles}>
{admissions.places_offered != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
<dt className={styles.admissionsTileLabel}>Places offered</dt>
</div>
)}
{admissions?.first_preference_applications != null && (
{admissions.first_preference_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
</div>
)}
{admissions?.first_preference_offer_pct != null && (
{admissions.first_preference_offer_pct != null && (
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
<dd className={styles.admissionsTileNum}>
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
@@ -147,25 +81,20 @@ export function AdmissionsSection({
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
</div>
)}
{admissions?.total_applications != null && (
{admissions.total_applications != null && (
<div className={styles.admissionsTile}>
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
</div>
)}
{distanceTile}
</dl>
{admissionsSummary?.chip && (
{admissionsSummary.chip && (
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
)}
{distanceNote}
</>
);
{/* Guarded rather than asserted: showAdmissionsTrend already requires
admissions, and tying the two together in one expression keeps that
invariant checked by the compiler instead of assumed. */}
const trendView = admissions && (
const trendView = (
<>
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
<AdmissionsTrendChart history={admissionsHistory} />
@@ -180,24 +109,18 @@ export function AdmissionsSection({
</>
);
const views: AdmissionsView[] = [
{ id: 'year', label: 'This year', content: yearView, className: styles.admissionsViewYear },
];
if (showAdmissionsTrend) {
views.push({
id: 'trend',
label: `${admissionsHistory.length}-year trend`,
content: trendView,
className: styles.admissionsViewTrend,
});
}
return (
<Section id="admissions">
{views.length > 1 ? (
<AdmissionsViewToggle title={title} subtitle={subtitle} views={views} />
{showAdmissionsTrend ? (
<AdmissionsViewToggle
title={title}
subtitle={subtitle}
trendLabel={`${admissionsHistory.length}-year trend`}
yearView={yearView}
trendView={trendView}
/>
) : (
/* One view — render statically, with no client component at all. */
/* No trend data — render statically, with no client component at all. */
<>
<div className={styles.admissionsHeader}>
<h2 className={styles.sectionTitle}>{title}</h2>
@@ -3,25 +3,14 @@
import { useState, type ReactNode } from 'react';
import styles from './schoolSections.module.css';
export interface AdmissionsView {
id: string;
/** Button text. "This year" and the "N-year trend" label are asserted by the
* e2e journey, so they are not free to drift. */
label: string;
content: ReactNode;
/** The year view sizes its tile grid to match the chart beside it, so each
* view keeps its own class rather than sharing one wrapper style. */
className?: string;
}
/**
* The only interactive part of the primary admissions section, and the only
* client component in components/school/.
*
* Every view is always present in the DOM and visibility is toggled with the
* `hidden` attribute, so the server-rendered markup passed in as content never
* ships as client JavaScript — this component carries the state and nothing
* else.
* Both views are always present in the DOM and visibility is toggled with the
* `hidden` attribute — matching the previous behaviour exactly — so the
* server-rendered markup passed in as yearView/trendView never ships as client
* JavaScript.
*
* It spans the header and the viewport because the segmented control sits
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
@@ -30,38 +19,39 @@ export interface AdmissionsView {
export function AdmissionsViewToggle({
title,
subtitle,
views,
trendLabel,
yearView,
trendView,
}: {
title: ReactNode;
subtitle: ReactNode;
views: AdmissionsView[];
trendLabel: string;
yearView: ReactNode;
trendView: ReactNode;
}) {
const [active, setActive] = useState(views[0]?.id);
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">
{views.map((v) => (
<button
key={v.id}
type="button"
aria-pressed={active === v.id}
onClick={() => setActive(v.id)}
>
{v.label}
</button>
))}
<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}>
{views.map((v) => (
<div key={v.id} className={v.className} hidden={active !== v.id}>
{v.content}
</div>
))}
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>
{yearView}
</div>
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>
{trendView}
</div>
</div>
</>
);
@@ -1,181 +0,0 @@
'use client';
/**
* CutoffMapPanel — "How far away are you?"
*
* Measures a postcode against the one cut-off we publish, and will draw that
* cut-off as a ring around the school on request.
*
* It used to compare against every published year and show a set of shrinking
* rings. Earlier years are now held back as a paid feature and no longer leave
* the API, so this answers one question about one year — which makes the
* verdict sharper to state, and puts more weight on qualifying it properly,
* since there is no run of years left to soften a single close call.
*
* The map is not rendered until asked for: before a postcode is entered it is a
* circle drawn round a school, and it costs a Leaflet bundle and 240px of
* height to say that. A successful check opens it automatically, because that
* is the point at which it starts answering something.
*
* The postcode never leaves the browser except to postcodes.io for a lat/long,
* and nothing is stored — this is a client-side measurement, not a lookup
* against the family.
*/
import { useState, type FormEvent } from 'react';
import dynamic from 'next/dynamic';
import type { School, SchoolAdmissionDistance } from '@/lib/types';
import { geocodePostcode, calculateDistance } from '@/lib/api';
import { isValidPostcode } from '@/lib/utils';
import {
compareToCutoff, CUTOFF_CHECK_CAVEAT,
type CutoffCheckResult, type CutoffVerdict,
} from './lastDistanceOffered';
import styles from './schoolSections.module.css';
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
ssr: false,
loading: () => <div className={styles.cutoffMapSkeleton} aria-hidden="true" />,
});
// Written out rather than composed from the verdict string. A computed
// `styles[...]` key silently yields undefined when a class is renamed, and an
// unstyled "outside" result would look exactly like an "inside" one.
const VERDICT_CLASS: Record<CutoffVerdict, string> = {
inside: styles.cutoffResultInside,
outside: styles.cutoffResultOutside,
'too-close': styles.cutoffResultTooClose,
};
export function CutoffMapPanel({
schoolInfo,
cutoff,
}: {
schoolInfo: School;
cutoff: SchoolAdmissionDistance;
}) {
const [postcode, setPostcode] = useState('');
const [home, setHome] = useState<{ lat: number; lng: number } | null>(null);
const [result, setResult] = useState<CutoffCheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [mapOpen, setMapOpen] = useState(false);
const lat = schoolInfo.latitude;
const lng = schoolInfo.longitude;
const cutoffM = cutoff.distance_m;
// Without coordinates there is nothing to measure against and nothing to draw.
if (lat == null || lng == null || cutoffM == null) return null;
const onCheck = async (e: FormEvent) => {
e.preventDefault();
const value = postcode.trim();
if (!value) return;
if (!isValidPostcode(value)) {
setError('That does not look like a UK postcode. Try one like SE23 3NA.');
setResult(null);
setHome(null);
return;
}
setBusy(true);
setError(null);
try {
const point = await geocodePostcode(value);
if (!point) {
setError('We could not find that postcode. Check it and try again.');
setResult(null);
setHome(null);
return;
}
setHome({ lat: point.latitude, lng: point.longitude });
setMapOpen(true);
// calculateDistance returns kilometres; everything here is metres.
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
setResult(compareToCutoff(metres, cutoffM, cutoff.year));
} catch {
setError('Something went wrong looking up that postcode. Try again in a moment.');
setResult(null);
setHome(null);
} finally {
setBusy(false);
}
};
return (
<div className={styles.cutoffCheck}>
<p className={styles.cutoffCheckSub}>
Straight-line distance from your postcode, compared with the September{' '}
{cutoff.year} cut-off. Not stored.
</p>
<form className={styles.cutoffCheckForm} onSubmit={onCheck}>
<label className={styles.srOnly} htmlFor="cutoff-postcode">Your postcode</label>
<input
id="cutoff-postcode"
type="text"
inputMode="text"
autoComplete="postal-code"
spellCheck={false}
placeholder="e.g. SE23 3NA"
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
className={styles.cutoffCheckInput}
/>
<button type="submit" className={styles.cutoffCheckButton} disabled={busy}>
{busy ? 'Checking…' : 'Check'}
</button>
</form>
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
{result && (
<div
className={`${styles.cutoffCheckResult} ${VERDICT_CLASS[result.verdict]}`}
role="status"
>
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
</div>
)}
{mapOpen ? (
<div className={styles.cutoffMapReveal}>
<div className={styles.cutoffMapFigure}>
<CutoffMap
lat={lat}
lng={lng}
rings={[{ year: cutoff.year, radiusM: cutoffM, latest: true }]}
home={home}
interactive={false}
/>
</div>
<ul className={styles.cutoffMapLegend}>
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
September {cutoff.year} cut-off
</li>
{home && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
Your postcode
</li>
)}
</ul>
</div>
) : (
<button
type="button"
className={styles.cutoffMapToggle}
onClick={() => setMapOpen(true)}
>
Show this distance on a map
</button>
)}
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
</div>
);
}
@@ -1,51 +0,0 @@
/**
* DistanceSection — "How far away are you?"
*
* The section exists to answer one question a parent cannot answer from a
* number alone: whether their own address falls inside it. The figure itself is
* already on the Admissions tile above; this turns it into something they can
* act on.
*
* Scope note. This used to carry the full published record — chart, year table
* and a set of shrinking rings. Earlier years are now held back as a paid
* feature and no longer leave the API at all, so what remains is the latest
* year and the check against it. The pipeline is unchanged: every published
* year is still loaded into fact_admission_distance, so restoring history for
* entitled callers is a serving change rather than a re-collection.
*
* Server component; the map and postcode form carry their own client boundary.
*/
import type { School, SchoolAdmissionDistance } from '@/lib/types';
import { Section, sectionStyles as styles } from './sectionShared';
import { CutoffMapPanel } from './CutoffMapPanel';
export function DistanceSection({
admissionDistance,
schoolInfo,
}: {
admissionDistance: SchoolAdmissionDistance | null;
schoolInfo: School;
}) {
// Without a figure there is nothing to compare against, and without
// coordinates there is nothing to measure — CutoffMapPanel enforces the
// second, but the section must not render an empty card either way.
if (
admissionDistance?.distance_m == null
|| schoolInfo.latitude == null
|| schoolInfo.longitude == null
) {
return null;
}
return (
<Section id="distance">
<h2 className={styles.sectionTitle}>How far away are you?</h2>
<p className={styles.sectionSubtitle}>
Check your postcode against the furthest home offered a place in
September {admissionDistance.year}.
</p>
<CutoffMapPanel schoolInfo={schoolInfo} cutoff={admissionDistance} />
</Section>
);
}
@@ -13,14 +13,13 @@
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SchoolFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { ResultsSection } from './ResultsSection';
import { AdmissionsSection } from './AdmissionsSection';
import { DistanceSection } from './DistanceSection';
import { InclusionSection } from './InclusionSection';
import { HistorySection } from './HistorySection';
import { SchoolLifeSection } from './SchoolLifeSection';
@@ -35,7 +34,6 @@ export interface PrimarySchoolSectionsProps {
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
@@ -44,8 +42,7 @@ export interface PrimarySchoolSectionsProps {
export function PrimarySchoolSections({
schoolInfo, yearlyData, absenceData, ofsted, census,
admissions, admissionsHistory, admissionDistance,
deprivation, finance, nationalAvg, flags,
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
}: PrimarySchoolSectionsProps) {
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -94,23 +91,14 @@ export function PrimarySchoolSections({
/>
)}
{/* Either source is enough to justify the section. The cut-off distance
and the EES admissions figures come from different places and a
minority of schools have one without the other — gating on admissions
alone would hide a published distance on those pages. */}
{(admissions || admissionDistance) && (
{admissions && (
<AdmissionsSection
admissions={admissions}
admissionsHistory={admissionsHistory}
admissionDistance={admissionDistance}
isAllThrough={flags.isAllThrough}
/>
)}
{/* Its own section, directly after Admissions: it answers the question
the tile above raises. */}
<DistanceSection admissionDistance={admissionDistance} schoolInfo={schoolInfo} />
{flags.hasInclusionData && (
<InclusionSection
latestResults={flags.latestResults}
@@ -6,26 +6,17 @@
* JavaScript at all. Server component.
*/
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import type { School, SchoolAdmissions } from '@/lib/types';
import { formatPercentage } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared';
import {
describeCutoff, describeCutoffAbsence,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
} from './lastDistanceOffered';
export function SecondaryAdmissionsSection({
admissions, admissionsHistory, admissionDistance, schoolInfo, hasSixthForm,
admissions, schoolInfo, hasSixthForm,
}: {
/* Nullable for the same reason as the primary section: a school can have a
published cut-off and no EES admissions row. */
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
admissions: SchoolAdmissions;
schoolInfo: School;
hasSixthForm: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
// Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
@@ -49,43 +40,32 @@ export function SecondaryAdmissionsSection({
)}
<div className={styles.metricsGrid}>
{admissions?.places_offered != null && (
{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 && (
{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 && (
{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 && (
{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>
)}
{cutoff && (
<div className={`${styles.metricCard} ${styles.metricCardDistance}`}>
<div className={styles.metricLabel}>
Last distance offered · {cutoff.entryYear}
</div>
<div className={styles.metricValue}>
{cutoff.primary}{' '}
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
</div>
</div>
)}
</div>
{admissions?.oversubscribed != null && (
{admissions.oversubscribed != null && (
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
{admissions.oversubscribed
? '⚠ Applications exceeded places last year'
@@ -93,24 +73,9 @@ export function SecondaryAdmissionsSection({
</div>
)}
{/* Replaces a blanket "distance cut-off data is not available for this
school", which was hardcoded onto every secondary page and was untrue
wherever the local authority does publish. The absence is now stated
only when it is real, and names the authority that would hold it. */}
{cutoff ? (
<p className={styles.admissionsDistanceNote}>
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
{cutoff.routeNote && <> {cutoff.routeNote}</>}
</p>
) : (
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
{describeCutoffAbsence({
localAuthority: schoolInfo.local_authority,
admissionsPolicy: schoolInfo.admissions_policy,
admissionsHistory,
})}
</p>
)}
<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}>
@@ -13,14 +13,13 @@
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SecondaryFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection';
import { GcseSection } from './GcseSection';
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
import { DistanceSection } from './DistanceSection';
import { SecondaryHistorySection } from './SecondaryHistorySection';
import { WellbeingSection } from './WellbeingSection';
import { FinancesSection } from './FinancesSection';
@@ -33,10 +32,6 @@ export interface SecondarySchoolSectionsProps {
ofsted: OfstedInspection | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
/** Needed to tell a year with no published cut-off apart from a year the
* school simply was not oversubscribed. */
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
@@ -45,8 +40,7 @@ export interface SecondarySchoolSectionsProps {
export function SecondarySchoolSections({
schoolInfo, yearlyData, ofsted, census,
admissions, admissionsHistory, admissionDistance,
deprivation, finance, nationalAvg, flags,
admissions, deprivation, finance, nationalAvg, flags,
}: SecondarySchoolSectionsProps) {
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -85,21 +79,14 @@ export function SecondarySchoolSections({
/>
)}
{/* See PrimarySchoolSections: distance and EES admissions are independent
sources, so either one warrants the section. */}
{(admissions || admissionDistance) && (
{admissions && (
<SecondaryAdmissionsSection
admissions={admissions}
admissionDistance={admissionDistance}
admissionsHistory={admissionsHistory}
schoolInfo={schoolInfo}
hasSixthForm={flags.hasSixthForm}
/>
)}
{/* See PrimarySchoolSections — same placement, same reason. */}
<DistanceSection admissionDistance={admissionDistance} schoolInfo={schoolInfo} />
{yearlyData.length > 1 && (
<SecondaryHistorySection
yearlyData={yearlyData}
@@ -1,212 +0,0 @@
/**
* Shared logic and copy for the "last distance offered" figure.
*
* The primary and secondary admissions sections lay their metrics out
* differently (a seamless tile grid vs. metric cards), so the markup is not
* shared — but the words are. Every caveat below is doing a specific job, and
* a figure that appeared on one template without them would be misleading in a
* way the other template was not:
*
* * The year, because a cut-off is the outcome of one particular admissions
* round and means nothing detached from it.
* * "not a fixed catchment", because that is the inference a parent will
* otherwise draw, and it is wrong — the distance moves every year.
* * The route count, because on a banded school the headline is the widest
* of several cut-offs and does not apply to every child.
*/
import type { SchoolAdmissionDistance } from '@/lib/types';
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
export interface CutoffDisplay {
/** Headline figure, e.g. "0.31 miles". */
primary: string;
/** Supporting conversion, e.g. "500 m". */
secondary: string;
/** Entry point the figure belongs to, e.g. "September 2025". */
entryYear: string;
/** Present only where several admission routes were collapsed into one figure. */
routeNote: string | null;
}
/** What the cut-off measures, and what it does not. Identical on both templates. */
export const CUTOFF_NOTE =
'The furthest home offered a place, after higher priorities such as siblings, ' +
'faith and EHCP were applied. It is not a fixed catchment — it moves every year ' +
'with the number of applications.';
/** Straight-line, because that is how councils measure it. */
export const CUTOFF_MEASUREMENT_NOTE = 'Measured in a straight line from the school.';
export function describeCutoff(
distance: SchoolAdmissionDistance | null | undefined
): CutoffDisplay | null {
if (!distance) return null;
const figure = formatCutoffDistance(distance.distance_m);
if (!figure) return null;
const routes = distance.route_count ?? 1;
return {
primary: figure.primary,
secondary: figure.secondary,
entryYear: formatEntryYear(distance.year),
routeNote:
routes > 1
? `Furthest of ${routes} admission routes at this school — the one that ` +
'applies to your child may have had a shorter cut-off.'
: null,
};
}
// ---------------------------------------------------------------------------
// "Would we have got in?"
// ---------------------------------------------------------------------------
/**
* How close a home has to be to the cut-off before the comparison stops
* meaning anything, in metres.
*
* postcodes.io returns the centroid of a postcode unit covering roughly fifteen
* addresses, so the home point carries error of this order before anything else
* is considered. Against a cut-off that is often only 500 m that is a fifth of
* the whole distance. Inside this band the honest answer is that we cannot
* tell, and saying "you would have been offered a place" would be inventing
* precision the inputs do not have.
*/
export const CUTOFF_UNCERTAINTY_M = 100;
export type CutoffVerdict = 'inside' | 'outside' | 'too-close';
export interface CutoffCheckResult {
/** Straight-line metres from the given postcode to the school. */
distanceM: number;
distanceLabel: string;
verdict: CutoffVerdict;
headline: string;
/** Why we cannot call it, on the one verdict that needs explaining. Kept
* apart from the headline so it does not run at headline weight. */
detail: string | null;
}
/**
* Compare a home against the one cut-off we publish.
*
* Only the latest year is compared because only the latest year is served:
* earlier years are held back as a paid feature and no longer leave the API.
* A single year makes the verdict sharper to state and easier to qualify — one
* distance, one year, one margin — but it also removes the reassurance a run of
* years gave, so the year is named in the headline rather than left implied.
*/
export function compareToCutoff(
distanceM: number,
cutoffM: number,
year: number,
): CutoffCheckResult {
const margin = cutoffM - distanceM;
const verdict: CutoffVerdict =
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
// Both sides through the same formatter, with no fallback that could reach
// for a different unit: the sentence compares these two numbers directly, so
// they have to be in the same one. formatCutoffDistance returns null for a
// zero or negative figure, and its old "N m" fallback here was one of the
// ways a metres reading used to appear next to a miles one.
const label = formatMiles(distanceM);
const cutoffLabel = formatMiles(cutoffM);
// "the September 2026 cut-off of 0.17 miles" rather than "the 0.17 miles
// cut-off for September 2026": the figure carries its own unit word, which
// reads wrong used attributively.
const cutoffPhrase = `the September ${year} cut-off of ${cutoffLabel}`;
const headline =
verdict === 'inside'
? `${label} away — inside ${cutoffPhrase}.`
: verdict === 'outside'
? `${label} away — beyond ${cutoffPhrase}.`
: `${label} away — too close to ${cutoffPhrase} to call.`;
const detail =
verdict === 'too-close'
? 'Your postcode is a centroid covering several addresses, so a margin '
+ `under ${CUTOFF_UNCERTAINTY_M} m is inside the measurement error.`
: null;
return { distanceM, distanceLabel: label, verdict, headline, detail };
}
/**
* The limits of the figure, and of the check made against it.
*
* One caveat, rendered once at the end of the section. It was previously three
* paragraphs — under the map, under the check, and a trailing "not a catchment"
* line — which took ~180px between them, said walking-route twice, and made
* the same point about priorities in two voices.
*
* Phrased to stand up whether or not the postcode check is on the page: it
* opens on the figure rather than on "your result", because a school with no
* coordinates renders the table with no check beneath it.
*
* Every claim is still here:
* * distance is the last criterion applied, not the first;
* * the figures and rings are straight-line, and not a boundary;
* * some authorities measure a walking route, always longer for the same home;
* * a past cut-off constrains next year's not at all.
*/
export const CUTOFF_CHECK_CAVEAT =
'Distance is the last criterion applied. Places go first to children in care, '
+ 'EHCP places, siblings and — at faith schools — on faith criteria, so a home '
+ 'inside the distance can still miss out. Figures are straight-line distances '
+ 'and not a catchment boundary; some authorities measure a walking route '
+ "instead, which is always longer for the same home. Next year's cut-off "
+ "depends on next year's applicants — always check the school's own "
+ 'admissions policy.';
// ---------------------------------------------------------------------------
// When there is no figure
// ---------------------------------------------------------------------------
/** The slice of an admissions year this file needs. */
interface AdmissionsYearLike {
year: number;
oversubscribed?: boolean | null;
}
interface AbsenceInput {
localAuthority?: string | null;
admissionsPolicy?: string | null;
admissionsHistory?: AdmissionsYearLike[];
}
/**
* Why this school has no cut-off distance, in the most useful terms available.
*
* "No data" is the least informative thing we could say, and for two of these
* cases it is also the most pessimistic reading of a fact that is either
* neutral or good news. The order matters: a selective school's absence is
* explained by how it admits, which outranks anything the publication record
* says.
*/
export function describeCutoffAbsence({
localAuthority,
admissionsPolicy,
admissionsHistory = [],
}: AbsenceInput): string {
const policy = (admissionsPolicy ?? '').toLowerCase();
if (policy.includes('selective')) {
return 'Places at this school are ranked by the entrance test rather than by '
+ 'distance, so no cut-off distance applies.';
}
const known = admissionsHistory.filter((a) => a.oversubscribed != null);
if (known.length >= 3 && known.every((a) => a.oversubscribed === false)) {
return `First preferences have not exceeded places in any of the last ${known.length} `
+ 'years, so this school has not needed a distance cut-off.';
}
return (localAuthority
? `${localAuthority} has not published a cut-off distance for this school.`
: 'No cut-off distance has been published for this school.')
+ ' Contact the admissions authority for its oversubscription criteria.';
}
@@ -778,38 +778,6 @@
word-break: break-word;
}
/* Last distance offered, secondary template. These cards are opaque
(--bg-secondary), so the tint can be set directly here — unlike the primary
tile grid, nothing shows through between them. */
.metricCardDistance {
background-color: var(--bg-card);
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
border-color: var(--brand);
/* Two columns' worth of width. Its label carries the entry year as well as
the measure, which at the 140px track width of the other cards wraps to
three lines and pushes the figure out of alignment with them. */
grid-column: span 2;
}
.metricCardDistance .metricValue {
flex-direction: column;
gap: 0.1rem;
color: var(--brand-strong);
}
.metricValueSub {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
}
/* Same measured reason as .admissionsTileDistance above: --text-muted falls to
4.09:1 on the tinted card in dark theme. */
.metricCardDistance .metricLabel,
.metricCardDistance .metricValueSub {
color: var(--text-secondary);
}
.metricHint {
font-size: 0.7rem;
color: var(--text-muted);
@@ -1485,62 +1453,6 @@
color: var(--brand-strong);
}
/* ── Admissions: last distance offered ──
*
* Spans the full grid width instead of taking a fifth half-cell. Two reasons:
* the tiles are a 2-column grid, so a fifth tile would sit alone next to a gap;
* and this is the figure the section is most often opened for, which a
* half-width cell would rank below "Applied in total".
*/
.admissionsTileDistance {
grid-column: 1 / -1;
/*
* --brand-bg is translucent, and the tile grid paints --border between
* cells, so a plain `background` would let the hairline colour show through
* and read as muddy rather than tinted. The opaque card colour is laid down
* first and the tint composited over it as an image.
*/
background-color: var(--bg-card);
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
}
.admissionsTileDistance .admissionsTileNum {
color: var(--brand-strong);
}
/*
* The tint costs the muted tokens their AA margin, so this tile does not use
* them.
*
* Measured on the composited backdrop, not the computed one: the tint is a
* background-image over --bg-card, so getComputedStyle reports the untinted
* card and flatters the result. Sampling the rendered pixel in dark theme gives
* a backdrop of rgb(34,59,64), on which --text-muted is 4.09:1 — under the 4.5
* floor — while --text-secondary is 6.50:1.
*
* The label is where the year lives, so it is the last thing on the tile that
* can afford to be hard to read: a distance without its year is not a fact
* about anything.
*/
.admissionsTileDistance .admissionsTileLabel,
.admissionsTileDistance .admissionsTileSub {
color: var(--text-secondary);
}
/*
* The caveats, not a footnote in the visual-noise sense: without them the
* figure reads as a catchment boundary. Kept at body contrast rather than
* --text-muted for that reason — it qualifies the number above it, so it has
* to be as readable as the number.
*/
.admissionsDistanceNote {
margin: 0.85rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 68ch;
}
.admissionsVerdict {
margin-top: 0.75rem;
margin-bottom: 0.25rem;
@@ -1627,23 +1539,8 @@
color: var(--text-secondary);
}
/*
* Stack both views in one grid cell so the card sizes to the taller view —
* toggling modes never shifts layout.
*
* This only works while the views are of COMPARABLE height, and it fails
* silently rather than loudly when they are not. The hidden view keeps its
* box (display stays block below; only visibility is dropped), so the grid
* row is sized by the tallest view and every other view is padded out to
* match it. A cut-off-distance view carrying a chart, a table and a map was
* added here and came to 1402px against the tile grid's 316px, which left
* the DEFAULT view — the one nobody had clicked — as four tiles floating in
* ~1080px of blank card.
*
* Anything substantially taller than the tile grid belongs in its own
* section, not in this viewport. That is where the distance detail now
* lives; see DistanceSection.
*/
/* Stack both views in one grid cell so the card sizes to the taller view —
toggling modes never shifts layout. */
.admissionsViewport {
display: grid;
}
@@ -1671,20 +1568,6 @@
grid-template-rows: 1fr 1fr;
}
/* The distance tile occupies a third row. Left implicit it would inherit the
1fr sizing of the row template and stretch to match the metric tiles above,
which are twice its content height. */
.admissionsViewYear .admissionsTilesWithDistance {
grid-template-rows: 1fr 1fr auto;
}
/* Cut-off distance but no EES admissions figures: there is nothing to put in
the two metric rows, and leaving them in the template paints an empty block
of the grid's gap colour beneath the tile. */
.admissionsViewYear .admissionsTilesDistanceOnly {
grid-template-rows: auto;
}
.admissionsChartCap {
font-size: 0.8125rem;
font-weight: 600;
@@ -1924,23 +1807,12 @@
border-radius: 4px 0 0 4px;
transition: width 0.6s ease;
}
/*
* Knockout, for the same reason as .natTick in SatsChart.module.css: this was
* var(--brand), identical to .att8VizFill above it, so where the marker
* crossed the school's own bar it measured 1.00:1 — invisible for exactly the
* schools at or above the national average.
*
* A light core with a dark edge reads on the teal fill and on the pale track
* alike, and both tokens flip with the theme. Keep this in step with
* SatsChart's .natTick; they are the same marker on two templates.
*/
.att8VizNatLine {
position: absolute;
top: -4px;
bottom: -4px;
width: 3px;
background: var(--bg-card);
box-shadow: 0 0 0 1px var(--text-primary);
width: 2px;
background: var(--brand);
border-radius: 2px;
z-index: 2;
}
@@ -2079,281 +1951,3 @@
.secondaryScope .ofstedReportLink { margin-left: 0; display: block; margin-top: 0.25rem; }
.secondaryScope .admissionsTypeBadge { font-size: 0.75rem; }
}
/* ══════════════════════════════════════════════════════════════════════
Last distance offered — the Distance view, map and postcode check
══════════════════════════════════════════════════════════════════════ */
/* Visually hidden but still read aloud. The year strip encodes its verdict as
a glyph, so every chip carries a spelled-out equivalent here — a screen
reader announcing "2025 ✓" tells you nothing. */
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* ── Map panel ── */
.cutoffMapPanel {
margin-top: 1.75rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.cutoffMapFigure {
/* Enough to read a set of concentric rings and no more — this is a
diagram of a number, not a map anyone navigates by. */
height: 240px;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg-secondary);
}
.cutoffMapSkeleton {
width: 100%;
height: 100%;
background: var(--bg-secondary);
}
.cutoffMapLegend {
list-style: none;
margin: 0.85rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.cutoffMapLegend li {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.cutoffSwatch {
width: 14px;
height: 14px;
border-radius: 50%;
flex: none;
}
.cutoffSwatchNow {
background: var(--brand-bg);
box-shadow: inset 0 0 0 2px var(--brand);
}
.cutoffSwatchHome {
width: 11px;
height: 11px;
border-radius: 2px;
background: var(--status-above);
transform: rotate(45deg);
}
.cutoffMapCaveat {
margin: 0.75rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 68ch;
}
/* ── Postcode check ── */
/* The check now leads the panel, so it carries no divider of its own — the
rule above it belonged to a map that used to sit there. */
.cutoffCheck {
margin-top: 0;
}
.cutoffCheckTitle {
margin: 0 0 0.25rem;
font-family: var(--font-display);
/* Now the panel's only heading (an h3), so it carries the weight the
retired .cutoffMapTitle used to. */
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
}
.cutoffCheckSub {
margin: 0 0 0.85rem;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 62ch;
}
.cutoffCheckForm {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.cutoffCheckInput {
font: inherit;
font-size: 1rem;
padding: 0.6rem 0.8rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-primary);
text-transform: uppercase;
/* Grows into the row but never past it — at 320px a fixed width would push
the button out of the viewport. */
flex: 1 1 9rem;
/* Stops well short of full width on desktop, where a postcode field
spanning the whole card reads as a search box. */
max-width: 16rem;
min-width: 0;
}
.cutoffCheckInput::placeholder {
text-transform: none;
color: var(--text-secondary);
}
.cutoffCheckInput:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 1px;
border-color: var(--brand);
}
.cutoffCheckButton {
font: inherit;
font-weight: 600;
font-size: 1rem;
padding: 0.6rem 1.25rem;
border: none;
border-radius: var(--radius-md);
background: var(--brand);
color: var(--brand-on);
cursor: pointer;
flex: none;
transition: background var(--transition);
}
.cutoffCheckButton:hover:not(:disabled) {
background: var(--brand-strong);
}
.cutoffCheckButton:disabled {
opacity: 0.65;
cursor: progress;
}
.cutoffCheckButton:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.cutoffCheckError {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: var(--status-below);
}
.cutoffCheckResult {
margin-top: 0.9rem;
padding: 0.9rem 1rem;
border-radius: var(--radius-md);
background-color: var(--bg-card);
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
}
.cutoffCheckHeadline {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
line-height: 1.35;
color: var(--text-primary);
text-wrap: balance;
}
.cutoffCheckDetail {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
}
.cutoffCheckCaveat {
margin: 0.9rem 0 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--text-secondary);
max-width: 68ch;
}
@media (max-width: 640px) {
.cutoffMapFigure {
height: 200px;
}
}
/* ── Map, revealed on request ── */
.cutoffMapToggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 1rem;
padding: 0.5rem 0.9rem;
font: inherit;
font-size: 0.875rem;
font-weight: 600;
color: var(--brand-strong);
background: none;
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
}
.cutoffMapToggle:hover {
border-color: var(--brand);
background: var(--brand-bg);
}
.cutoffMapToggle:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.cutoffMapReveal {
margin-top: 1rem;
}
/* ── Postcode check result, coloured by verdict ──
*
* These override the neutral brand tint on .cutoffCheckResult. Without them the
* three outcomes render identically, which is the specific failure the class
* map in CutoffMapPanel is written out longhand to avoid — and which the
* stylesheet test caught when they were referenced before being defined.
*/
.cutoffResultInside {
background-image: linear-gradient(var(--status-above-bg), var(--status-above-bg));
}
.cutoffResultOutside {
background-image: linear-gradient(var(--status-below-bg), var(--status-below-bg));
}
/* Deliberately neutral: "we cannot tell" is not a bad outcome, and tinting it
like one would push a reader toward a conclusion the data does not support. */
.cutoffResultTooClose {
background-image: none;
background-color: var(--bg-secondary);
}
-57
View File
@@ -1,57 +0,0 @@
/**
* Client for the places API.
*
* Two namespaces, matching the backend: towns and localities share
* /schools/[place]; authorities take /schools/authority/[la]. 67 town names
* collide with an authority name and neither set contains the other, so one
* namespace would publish near-duplicate pages.
*/
import type { School } from '@/lib/types';
export interface PlaceSummary {
kind: string;
slug: string;
name: string;
count: number;
}
export interface PlaceDetail {
place: PlaceSummary & { parent_authority: string | null };
schools: School[];
averages: {
rwm_expected_pct: number | null;
attainment_8_score: number | null;
};
}
export function placeUrl(kind: string, slug: string, phase?: string): string {
const base =
kind === 'authority' ? `/schools/authority/${slug}`
: kind === 'outcode' ? `/schools/near/${slug}`
: `/schools/${slug}`;
return phase ? `${base}/${phase}` : base;
}
/** An authority name as it appears in a URL. */
export function authoritySlug(name: string): string {
return name.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-');
}
const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
|| 'http://localhost:8000/api';
export async function fetchPlaces(): Promise<PlaceSummary[]> {
const res = await fetch(`${API}/places`, { next: { revalidate: 604800 } });
if (!res.ok) return [];
return (await res.json()).places ?? [];
}
export async function fetchPlace(
kind: string, slug: string, phase?: string,
): Promise<PlaceDetail | null> {
const q = phase ? `?phase=${encodeURIComponent(phase)}` : '';
const res = await fetch(`${API}/places/${kind}/${slug}${q}`,
{ next: { revalidate: 604800 } });
if (!res.ok) return null;
return res.json();
}
+5 -18
View File
@@ -9,7 +9,7 @@
import type {
School, SchoolResult, AbsenceData, SchoolCensus,
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance,
} from './types';
import { isSpecialSchool } from './utils';
@@ -119,13 +119,6 @@ export function computeSchoolFlags({
export interface NavItemsInput {
ofsted: OfstedInspection | null;
admissions: SchoolAdmissions | null;
/** A published cut-off distance renders the Admissions section on its own,
* so it has to open the nav entry too. */
admissionDistance: SchoolAdmissionDistance | null;
/** Whether the school has coordinates — the Distance section needs them to
* measure a postcode, so the nav must gate on them too or it will link to an
* anchor that was never rendered. */
hasLocation?: boolean;
yearlyDataLength: number;
}
@@ -140,7 +133,7 @@ export interface NavItemsInput {
*/
export function buildNavItems(
flags: SchoolFlags,
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
@@ -150,10 +143,7 @@ export function buildNavItems(
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
});
}
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (admissionDistance?.distance_m != null && hasLocation) {
navItems.push({ id: 'distance', label: 'Distance' });
}
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
@@ -223,15 +213,12 @@ export function computeSecondaryFlags({
*/
export function buildSecondaryNavItems(
flags: SecondaryFlags,
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (admissionDistance?.distance_m != null && hasLocation) {
navItems.push({ id: 'distance', label: 'Distance' });
}
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
-19
View File
@@ -1,19 +0,0 @@
/**
* The one place the site's absolute origin is written down.
*
* The apex domain 301s to www at Cloudflare, so www is the host that actually
* serves a 200. Canonicals, og:url, sitemap <loc> entries and the robots.txt
* Sitemap: line must all agree with it — a canonical pointing at a redirect
* makes Google resolve the hop before it can consolidate the signal.
*
* backend/app.py holds the same value as BASE_URL for the sitemap. The two are
* asserted against each other by the e2e journeys rather than shared at build
* time, because the backend and frontend ship as separate images.
*/
export const SITE_URL = 'https://www.schoolcompare.co.uk';
/** Absolute URL for a site-relative path. Tolerates a missing leading slash. */
export function absoluteUrl(path: string): string {
const rooted = path.startsWith('/') ? path : `/${path}`;
return `${SITE_URL}${rooted}`;
}
-29
View File
@@ -1,29 +0,0 @@
/**
* Runtime proxy for the sitemap family → the FastAPI backend.
*
* 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 at /sitemap.xml, which is the index;
* the index names children under /sitemaps/, which land on the same proxy.
*/
import { NextResponse } from 'next/server';
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 proxySitemap(path: string): Promise<NextResponse> {
let upstream: Response;
try {
upstream = await fetch(`${backendOrigin()}${path}`, { 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' },
});
}
-32
View File
@@ -163,30 +163,6 @@ export interface SchoolAdmissions {
}
/**
* Last distance offered — how far from the school the last child to be given a
* place that year lived.
*
* Not a catchment: it is the outcome of one year's applications and moves every
* year. Only published by the local authorities that choose to, so most schools
* have none of this at all.
*/
export interface SchoolAdmissionDistance {
/** Entry year the cut-off belongs to — always shown alongside the figure. */
year: number;
/** Straight-line distance in metres. */
distance_m: number | null;
/**
* Admission routes collapsed into this figure (ability bands, or separate
* reception and junior intakes). Above 1 the distance is the furthest of
* several, which the page has to say out loud.
*/
route_count: number | null;
la_name: string | null;
/** The unit the council published in ("miles", "meters", "km"). */
distance_unit_raw: string | null;
}
export interface SchoolDeprivation {
lsoa_code: string | null;
idaci_score: number | null;
@@ -354,14 +330,6 @@ export interface SchoolDetailsResponse {
admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */
admissions_history: SchoolAdmissions[];
/**
* Latest published cut-off distance, or null where the LA publishes none.
*
* Only the latest year is served. Earlier years exist in the mart but are
* held back as a paid feature and are not part of this public payload — see
* data_loader._admission_distance.
*/
admission_distance: SchoolAdmissionDistance | null;
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
}
-62
View File
@@ -502,68 +502,6 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri
}
}
const METRES_PER_MILE = 1609.344;
/**
* A distance in miles, the unit UK school admissions is conducted in.
*
* Councils publish cut-offs in miles — 90% of the collected source rows — and
* it is the unit a parent has already been quoted in their council's booklet
* and offer letter. Everything a reader is asked to compare therefore renders
* through this one function, so two figures in the same sentence can never
* arrive in different units.
*
* That was not previously true. This formatter used to swap to metres below
* 100m, on the reasoning that "0.04 miles" carries less for a reader than
* "69 m". Taken one figure at a time that holds; taken in a sentence it
* produced "69 m away — inside the September 2026 cut-off of 0.17 miles",
* which asks the reader to convert between units to understand a comparison we
* had already made for them. Legibility of a single number lost to coherence
* of the pair.
*
* Below 0.01 miles the two decimal places run out rather than the unit being
* wrong, so the figure is described instead of rounded to a flat "0.00 miles".
*/
export function formatMiles(metres: number): string {
const miles = metres / METRES_PER_MILE;
if (miles < 0.01) return 'under 0.01 miles';
return `${miles.toFixed(2)} miles`;
}
/**
* Format an admission cut-off distance: the headline figure in miles, and a
* metric equivalent to support it.
*
* The metric figure is support, not an alternative — it appears beside the
* miles figure, never instead of it, so nothing a reader compares is ever in
* two units at once.
*/
export function formatCutoffDistance(
metres: number | null | undefined
): { primary: string; secondary: string } | null {
if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
return {
primary: formatMiles(metres),
secondary: metres < 1000
? `${Math.round(metres / 10) * 10} m`
: `${(metres / 1000).toFixed(1)} km`,
};
}
/**
* The entry point a cut-off belongs to, phrased the way councils phrase it.
*
* The distance year is a plain entry year (2025 = the September 2025 intake),
* not the six-digit academic year EES uses, so formatAcademicYear would render
* it as "2025/26" and invite the reader to think of it as a school year rather
* than an application round.
*/
export function formatEntryYear(year: number | null | undefined): string {
if (year == null) return '';
return `September ${year}`;
}
export function formatAcademicYear(year: number | null | undefined): string {
if (year == null) return '';
const s = year.toString();
-31
View File
@@ -55,37 +55,6 @@ const nextConfig = {
// Headers for caching and security
async headers() {
return [
{
/*
* Keep non-production hosts out of the index.
*
* Staging serves the same image as production off stx., so without
* this it is a full crawlable duplicate of the site.
*
* X-Robots-Tag, NOT a robots.txt Disallow. Disallow blocks crawling,
* which is not the same as blocking indexing — a disallowed URL can
* still be indexed from external links, and worse, blocking the crawl
* means Google never fetches the page and never sees a noindex at all.
* Staging therefore stays crawlable and answers "noindex" when crawled.
*
* Matched on the staging host explicitly rather than "any host that is
* not production". The inverted form is tempting because it would cover
* future environments automatically, but its failure mode is
* deindexing production if the Host header ever arrives rewritten by a
* proxy. This form's failure mode is a new environment being indexable
* until someone adds it here — recoverable, where the other is not.
*
* Any new non-production hostname must be added to this list.
*/
source: '/:path*',
has: [{ type: 'host', value: 'stx.schoolcompare.co.uk' }],
headers: [
{
key: 'X-Robots-Tag',
value: 'noindex, nofollow',
},
],
},
{
source: '/:path*',
headers: [
+1 -1
View File
@@ -24,7 +24,7 @@ The landing hero illustration: a path climbing through hills to a schoolhouse,
with an empty cream area on the left reserved for the headline.
- **Source:** supplied by the project owner
- **Master:** `nextjs-app/assets/hero-source.png` (2000×1125 PNG), committed so
- **Master:** `nextjs-app/assets/hero-source.png` (1672×941 PNG), committed so
the derivatives can be regenerated
- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these
- **Licence:** **to be confirmed by the project owner**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+12 -23
View File
@@ -45,24 +45,14 @@ const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png');
const OUT = path.join(__dirname, '..', 'public', 'brand');
/*
* Band crop, in source pixels, measured against the artwork rather than
* guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
* up at y 330.
*
* The crop is flush to the right edge because it has to be. The school sits at
* about 82% across the frame, so there is only ~140px of artwork to its right
* — not enough to centre it. Anchoring right and taking 1050px back puts the
* school at ~66% across the crop, which is what BAND_FOCUS_X below reports to
* the CSS so the narrowest band crops the empty left side rather than the
* subject.
* Band crop, in source pixels. The schoolhouse occupies roughly x 1180–1540,
* y 300–520, so this keeps it whole with headroom above the flag and enough
* hillside below to read as landscape rather than as a cutout.
*/
const BAND = { left: 656, top: 300, width: 1344, height: 420 };
const BAND = { left: 700, top: 260, width: 972, height: 380 };
/** Where the schoolhouse lands in the band crop, for CSS object-position. */
const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100);
const WIDE_WIDTHS = [2000, 1400, 1000];
const BAND_WIDTHS = [1344, 900, 600];
const WIDE_WIDTHS = [1672, 1200, 900];
const BAND_WIDTHS = [972, 700, 500];
async function emit(pipeline, name, width) {
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
@@ -77,26 +67,25 @@ async function main() {
const meta = await sharp(SRC).metadata();
console.log(`source ${meta.width}×${meta.height}`);
console.log(`band focus x: ${BAND_FOCUS_X}% (keep .heroArt object-position in step)`);
const wide = sharp(SRC);
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w);
// Fallback for the <img> itself. Flattened onto the artwork's own cream so a
// JPEG (no alpha) can never composite against black.
await sharp(SRC)
.resize({ width: 1400 })
.flatten({ background: '#FEF2E1' })
.resize({ width: 1200 })
.flatten({ background: '#FDF9F3' })
.jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-wide-1400.jpg'));
.toFile(path.join(OUT, 'hero-wide-1200.jpg'));
const band = sharp(SRC).extract(BAND);
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
await sharp(SRC)
.extract(BAND)
.resize({ width: 900 })
.flatten({ background: '#FEF2E1' })
.resize({ width: 700 })
.flatten({ background: '#FDF9F3' })
.jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-band-900.jpg'));
.toFile(path.join(OUT, 'hero-band-700.jpg'));
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
let total = 0;
-65
View File
@@ -224,68 +224,3 @@ with DAG(
)
extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci
# ── Annual DAG (Last distance offered) ────────────────────────────────
with DAG(
dag_id="school_data_annual_distance",
default_args=default_args,
description="Last distance offered (LA admission cut-offs) extraction and transform",
# Councils publish on allocation day (March for secondary, April for
# primary) and each one on its own timetable, so there is no date worth
# scheduling against. Triggered manually after a collection run refreshes
# the CSV.
schedule=None,
start_date=datetime(2025, 1, 1),
catchup=False,
tags=["school-compare", "annual"],
) as annual_distance_dag:
extract_distance = BashOperator(
task_id="extract_distance",
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-school-distance target-postgres",
)
# Coverage is the thing that silently rots here: the CSV is assembled by
# hand from council publications, so a collection run that half-failed
# produces a valid file with a fraction of the schools in it. A row count
# alone would not catch that — losing an entire local authority leaves the
# total looking healthy — so the floor is checked on distinct LAs too.
validate_distance = BashOperator(
task_id="validate_distance",
bash_command=f"""
cd {PIPELINE_DIR} && python -c "
import psycopg2, os, sys
conn = psycopg2.connect(
host=os.environ.get('PG_HOST', 'localhost'),
port=os.environ.get('PG_PORT', '5432'),
user=os.environ.get('PG_USER', 'postgres'),
password=os.environ.get('PG_PASSWORD', 'postgres'),
dbname=os.environ.get('PG_DATABASE', 'school_compare'),
)
cur = conn.cursor()
cur.execute('SELECT count(*), count(distinct urn), count(distinct la_code) FROM raw.school_distance_offered')
rows, urns, las = cur.fetchone()
conn.close()
print(f'Loaded {{rows}} rows, {{urns}} schools, {{las}} local authorities')
if rows < 7000 or urns < 3000 or las < 45:
print('ERROR: distance extract is short of expected coverage '
'(baseline 2026-08: 9128 rows / 3726 schools / 57 LAs)', file=sys.stderr)
sys.exit(1)
print('Validation passed')
"
""",
)
dbt_build_distance = BashOperator(
task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_school_distance+ fact_admission_distance+",
)
invalidate_cache_distance = BashOperator(
task_id="invalidate_cache",
bash_command=INVALIDATE_CACHE_CMD,
)
extract_distance >> validate_distance >> dbt_build_distance >> invalidate_cache_distance
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more