Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914b429a28 |
No files matched your search
@@ -5,9 +5,3 @@ __pycache__/
|
|||||||
pipeline/transform/target/
|
pipeline/transform/target/
|
||||||
pipeline/transform/logs/
|
pipeline/transform/logs/
|
||||||
pipeline/transform/.user.yml
|
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/
|
|
||||||
@@ -7,7 +7,6 @@ Uses real data from UK Government Compare School Performance downloads.
|
|||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -35,7 +34,6 @@ from .data_loader import (
|
|||||||
search_schools_typesense,
|
search_schools_typesense,
|
||||||
)
|
)
|
||||||
from .data_loader import get_data_info as get_db_info
|
from .data_loader import get_data_info as get_db_info
|
||||||
from .places import build_place_registry
|
|
||||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||||
from .utils import clean_for_json, convert_to_native
|
from .utils import clean_for_json, convert_to_native
|
||||||
|
|
||||||
@@ -50,20 +48,11 @@ PHASE_GROUPS: dict[str, set[str]] = {
|
|||||||
"all-through": {"all-through"},
|
"all-through": {"all-through"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Must match SITE_URL in nextjs-app/lib/site.ts. The apex 301s to www, and a
|
BASE_URL = "https://schoolcompare.co.uk"
|
||||||
# sitemap <loc> that redirects wastes a crawl on every URL it lists.
|
|
||||||
BASE_URL = "https://www.schoolcompare.co.uk"
|
|
||||||
MAX_SLUG_LENGTH = 60
|
MAX_SLUG_LENGTH = 60
|
||||||
|
|
||||||
# In-memory sitemap cache: name -> XML. Populated on startup and by the admin
|
# In-memory sitemap cache
|
||||||
# regenerate endpoint after a pipeline run.
|
_sitemap_xml: str | None = None
|
||||||
_sitemaps: dict[str, str] | None = None
|
|
||||||
|
|
||||||
# Built from the same DataFrame the sitemap uses, so places and sitemap can
|
|
||||||
# never describe different corpora. Reset by the same admin endpoint.
|
|
||||||
_place_registry: dict | None = None
|
|
||||||
|
|
||||||
VALID_PLACE_KINDS = ("town", "locality", "authority", "outcode")
|
|
||||||
|
|
||||||
|
|
||||||
def _slugify(text: str) -> str:
|
def _slugify(text: str) -> str:
|
||||||
@@ -81,206 +70,43 @@ def _school_url(urn: int, school_name: str) -> str:
|
|||||||
return f"/school/{urn}-{slug}"
|
return f"/school/{urn}-{slug}"
|
||||||
|
|
||||||
|
|
||||||
# Routes worth submitting that are not a school page. /admissions was missing
|
def build_sitemap() -> str:
|
||||||
# from the sitemap entirely despite being a static, indexable guide.
|
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
|
||||||
STATIC_SITEMAP_PATHS = ("/", "/rankings", "/compare", "/admissions")
|
|
||||||
|
|
||||||
|
|
||||||
# A page has something a search result could state if any of these is present
|
|
||||||
# in any year. Shared by _has_publishable_data and the per-school check in
|
|
||||||
# _school_sitemap_rows so the two can never drift.
|
|
||||||
_PUBLISHABLE_FIELDS = ("rwm_expected_pct", "attainment_8_score", "ofsted_grade")
|
|
||||||
|
|
||||||
|
|
||||||
def _has_publishable_data(row) -> bool:
|
|
||||||
"""True when a school page has something a search result could state.
|
|
||||||
|
|
||||||
A school with no results in any year and no Ofsted grade renders an empty
|
|
||||||
page. Submitting it spends crawl budget and drags the corpus-wide quality
|
|
||||||
signal down, so it stays out of the sitemap. The page itself still resolves
|
|
||||||
for anyone who has the URL.
|
|
||||||
"""
|
|
||||||
for field in _PUBLISHABLE_FIELDS:
|
|
||||||
value = row.get(field)
|
|
||||||
if value is not None and not pd.isna(value):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _url_element(loc: str, lastmod: str | None = None) -> str:
|
|
||||||
"""One <url> entry. No priority or changefreq — Google ignores both."""
|
|
||||||
body = f"<loc>{loc}</loc>"
|
|
||||||
if lastmod:
|
|
||||||
body += f"<lastmod>{lastmod}</lastmod>"
|
|
||||||
return f" <url>{body}</url>"
|
|
||||||
|
|
||||||
|
|
||||||
def _school_sitemap_rows(df) -> list[str]:
|
|
||||||
"""A <url> element per school that has something to show.
|
|
||||||
|
|
||||||
lastmod comes from the school's Ofsted date where there is one and is
|
|
||||||
omitted otherwise. An always-now lastmod is a claim Google learns to
|
|
||||||
distrust; an absent one honestly means "unknown".
|
|
||||||
"""
|
|
||||||
if df.empty or "urn" not in df.columns or "school_name" not in df.columns:
|
|
||||||
return []
|
|
||||||
|
|
||||||
rows: list[str] = []
|
|
||||||
seen: set[int] = set()
|
|
||||||
|
|
||||||
# Publishable is a property of the SCHOOL, not of its latest row.
|
|
||||||
#
|
|
||||||
# The first cut tested the latest year's row alone, which quietly dropped
|
|
||||||
# every school that has results in its history but a null row for the most
|
|
||||||
# recent year — a school that stopped reporting, or whose figures were
|
|
||||||
# suppressed for small-cohort disclosure. The Mallard Academy (150367) is
|
|
||||||
# the case that caught it: real KS2 results for 2015-16 through 2018-19,
|
|
||||||
# then null rows for 2022-23 onward. Its page shows all four years; the
|
|
||||||
# sitemap omitted it. Roughly 220 schools were affected.
|
|
||||||
publishable_cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
|
|
||||||
publishable: set[int] = (
|
|
||||||
set(df.loc[df[publishable_cols].notna().any(axis=1), "urn"].astype(int))
|
|
||||||
if publishable_cols else set()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Latest row per URN first, so a school's most recent Ofsted date wins.
|
|
||||||
ordered = df.sort_values("year", ascending=False) if "year" in df.columns else df
|
|
||||||
|
|
||||||
for _, row in ordered.iterrows():
|
|
||||||
urn = int(row["urn"])
|
|
||||||
if urn in seen:
|
|
||||||
continue
|
|
||||||
seen.add(urn)
|
|
||||||
if urn not in publishable:
|
|
||||||
continue
|
|
||||||
|
|
||||||
lastmod = None
|
|
||||||
ofsted_date = row.get("ofsted_date")
|
|
||||||
if ofsted_date is not None and not pd.isna(ofsted_date):
|
|
||||||
lastmod = pd.Timestamp(ofsted_date).date().isoformat()
|
|
||||||
|
|
||||||
rows.append(_url_element(
|
|
||||||
BASE_URL + _school_url(urn, str(row["school_name"])), lastmod))
|
|
||||||
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
# Sitemaps cap at 50,000 URLs per file. 10,000 keeps a child small enough to
|
|
||||||
# scan by eye in Search Console, which is the point of splitting at all:
|
|
||||||
# coverage is reported per submitted sitemap, so one file per page family is
|
|
||||||
# what makes an indexation problem attributable to a family.
|
|
||||||
SITEMAP_CHUNK_SIZE = 10_000
|
|
||||||
|
|
||||||
# Children are served under /sitemaps/ because Next.js only treats a whole
|
|
||||||
# bracketed path segment as dynamic — a route folder named "sitemap-[...parts]"
|
|
||||||
# is read as a literal static segment and never matches.
|
|
||||||
SITEMAP_CHILD_PREFIX = "/sitemaps"
|
|
||||||
|
|
||||||
|
|
||||||
def get_place_registry() -> dict:
|
|
||||||
"""The place registry, built once and cached for the process."""
|
|
||||||
global _place_registry
|
|
||||||
if _place_registry is None:
|
|
||||||
_place_registry = build_place_registry(load_school_data())
|
|
||||||
return _place_registry
|
|
||||||
|
|
||||||
|
|
||||||
def _urlset(rows: list[str]) -> str:
|
|
||||||
return "\n".join([
|
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
||||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
||||||
*rows,
|
|
||||||
"</urlset>",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def _place_url(place) -> str:
|
|
||||||
"""The canonical path for a place. Two namespaces, per the spec.
|
|
||||||
|
|
||||||
Towns and localities share /schools/[place]; authorities take their own
|
|
||||||
prefix because 67 town names collide with an authority name and neither
|
|
||||||
set contains the other.
|
|
||||||
"""
|
|
||||||
if place.kind == "authority":
|
|
||||||
return f"/schools/authority/{place.slug}"
|
|
||||||
if place.kind == "outcode":
|
|
||||||
return f"/schools/near/{place.slug}"
|
|
||||||
return f"/schools/{place.slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]:
|
|
||||||
"""A <url> per place, plus a phase variant wherever that phase clears the
|
|
||||||
threshold on its own.
|
|
||||||
|
|
||||||
Phase is part of the query — "primary schools in beccles" — so each
|
|
||||||
variant is its own indexable page. Submitting only the bare place URL left
|
|
||||||
~950 of them reachable by nothing: absent from every sitemap, and not
|
|
||||||
linked from the place page either.
|
|
||||||
"""
|
|
||||||
rows: list[str] = []
|
|
||||||
for p in sorted(get_place_registry().values(), key=lambda p: (p.kind, p.slug)):
|
|
||||||
if p.kind not in kinds:
|
|
||||||
continue
|
|
||||||
rows.append(_url_element(BASE_URL + _place_url(p)))
|
|
||||||
# Outcodes carry no phase variants: nobody searches "primary schools
|
|
||||||
# in SW11", so the routes do not exist to submit.
|
|
||||||
if p.kind == "outcode":
|
|
||||||
continue
|
|
||||||
for phase in ("primary", "secondary"):
|
|
||||||
if p.publishes_phase(phase):
|
|
||||||
rows.append(_url_element(f"{BASE_URL}{_place_url(p)}/{phase}"))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def build_sitemaps() -> dict[str, str]:
|
|
||||||
"""Build the sitemap index and every child, keyed by name."""
|
|
||||||
df = load_school_data()
|
df = load_school_data()
|
||||||
|
|
||||||
children: dict[str, str] = {
|
static_urls = [
|
||||||
"static.xml": _urlset(
|
(BASE_URL + "/", "daily", "1.0"),
|
||||||
[_url_element(BASE_URL + path) for path in STATIC_SITEMAP_PATHS]),
|
(BASE_URL + "/rankings", "weekly", "0.8"),
|
||||||
}
|
(BASE_URL + "/compare", "weekly", "0.8"),
|
||||||
|
|
||||||
school_rows = _school_sitemap_rows(df)
|
|
||||||
# Always emit at least one school child, so the index shape is stable even
|
|
||||||
# on an empty database.
|
|
||||||
chunks = [school_rows[i:i + SITEMAP_CHUNK_SIZE]
|
|
||||||
for i in range(0, len(school_rows), SITEMAP_CHUNK_SIZE)] or [[]]
|
|
||||||
for n, chunk in enumerate(chunks, start=1):
|
|
||||||
children[f"schools-{n}.xml"] = _urlset(chunk)
|
|
||||||
|
|
||||||
# Separate children per family: Search Console reports coverage per
|
|
||||||
# submitted sitemap, which is how the location layer's indexation is
|
|
||||||
# measured apart from the school pages'.
|
|
||||||
for label, kinds in (("places", ("town", "locality", "authority")),
|
|
||||||
("outcodes", ("outcode",))):
|
|
||||||
rows = _place_sitemap_rows(kinds)
|
|
||||||
chunks = [rows[i:i + SITEMAP_CHUNK_SIZE]
|
|
||||||
for i in range(0, len(rows), SITEMAP_CHUNK_SIZE)] or [[]]
|
|
||||||
for n, chunk in enumerate(chunks, start=1):
|
|
||||||
children[f"{label}-{n}.xml"] = _urlset(chunk)
|
|
||||||
|
|
||||||
# On a sitemap index, lastmod means "when this sitemap file last changed",
|
|
||||||
# so generation time is the correct value here — unlike on a <url>, where
|
|
||||||
# it would be a claim about content we cannot support.
|
|
||||||
generated = datetime.now(timezone.utc).date().isoformat()
|
|
||||||
index_rows = [
|
|
||||||
f" <sitemap><loc>{BASE_URL}{SITEMAP_CHILD_PREFIX}/{name}</loc>"
|
|
||||||
f"<lastmod>{generated}</lastmod></sitemap>"
|
|
||||||
for name in children
|
|
||||||
]
|
]
|
||||||
index = "\n".join([
|
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
||||||
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
||||||
*index_rows,
|
|
||||||
"</sitemapindex>",
|
|
||||||
])
|
|
||||||
return {**children, "sitemap.xml": index}
|
|
||||||
|
|
||||||
|
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
||||||
|
|
||||||
def build_sitemap() -> str:
|
for url, freq, priority in static_urls:
|
||||||
"""The sitemap index. Kept for `lifespan` and the admin endpoint."""
|
lines.append(
|
||||||
return build_sitemaps()["sitemap.xml"]
|
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]:
|
def clean_filter_values(series: pd.Series) -> list[str]:
|
||||||
@@ -458,7 +284,7 @@ def validate_postcode(postcode: Optional[str]) -> Optional[str]:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan - startup and shutdown events."""
|
"""Application lifespan - startup and shutdown events."""
|
||||||
global _sitemaps
|
global _sitemap_xml
|
||||||
print("Loading school data from marts...")
|
print("Loading school data from marts...")
|
||||||
df = load_school_data()
|
df = load_school_data()
|
||||||
if df.empty:
|
if df.empty:
|
||||||
@@ -468,9 +294,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# Pre-compute the latest-year snapshot so the first search request is fast
|
# Pre-compute the latest-year snapshot so the first search request is fast
|
||||||
await asyncio.to_thread(load_latest_school_data)
|
await asyncio.to_thread(load_latest_school_data)
|
||||||
try:
|
try:
|
||||||
_sitemaps = build_sitemaps()
|
_sitemap_xml = build_sitemap()
|
||||||
n = sum(x.count("<url>") for x in _sitemaps.values())
|
n = _sitemap_xml.count("<url>")
|
||||||
print(f"Sitemaps built: {len(_sitemaps)} files, {n} URLs.")
|
print(f"Sitemap built: {n} URLs.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: sitemap build failed on startup: {e}")
|
print(f"Warning: sitemap build failed on startup: {e}")
|
||||||
|
|
||||||
@@ -806,7 +632,6 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"census": supplementary.get("census"),
|
"census": supplementary.get("census"),
|
||||||
"admissions": supplementary.get("admissions"),
|
"admissions": supplementary.get("admissions"),
|
||||||
"admissions_history": supplementary.get("admissions_history") or [],
|
"admissions_history": supplementary.get("admissions_history") or [],
|
||||||
"admission_distance": supplementary.get("admission_distance"),
|
|
||||||
"sen_detail": supplementary.get("sen_detail"),
|
"sen_detail": supplementary.get("sen_detail"),
|
||||||
"phonics": supplementary.get("phonics"),
|
"phonics": supplementary.get("phonics"),
|
||||||
"deprivation": supplementary.get("deprivation"),
|
"deprivation": supplementary.get("deprivation"),
|
||||||
@@ -1181,66 +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,
|
|
||||||
# Only phases that clear the threshold, so the page links
|
|
||||||
# variants that exist rather than 404s.
|
|
||||||
"phases": [ph for ph in ("primary", "secondary")
|
|
||||||
if place.publishes_phase(ph)]},
|
|
||||||
"schools": clean_for_json(rows[cols]),
|
|
||||||
"averages": averages,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/data-info")
|
@app.get("/api/data-info")
|
||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_data_info(request: Request):
|
async def get_data_info(request: Request):
|
||||||
@@ -1321,28 +1086,16 @@ async def robots_txt():
|
|||||||
return FileResponse(settings.frontend_dir / "robots.txt", media_type="text/plain")
|
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")
|
@app.get("/sitemap.xml")
|
||||||
async def sitemap_xml():
|
async def sitemap_xml():
|
||||||
"""Serve the sitemap index."""
|
"""Serve sitemap.xml for search engine indexing."""
|
||||||
return _serve_sitemap("sitemap.xml")
|
global _sitemap_xml
|
||||||
|
if _sitemap_xml is None:
|
||||||
|
try:
|
||||||
@app.get("/sitemaps/{name}")
|
_sitemap_xml = build_sitemap()
|
||||||
async def sitemap_child(name: str):
|
except Exception as e:
|
||||||
"""Serve a child sitemap (static.xml, or schools-N.xml)."""
|
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
|
||||||
return _serve_sitemap(name)
|
return Response(content=_sitemap_xml, media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/regenerate-sitemap")
|
@app.post("/api/admin/regenerate-sitemap")
|
||||||
@@ -1352,14 +1105,10 @@ async def regenerate_sitemap(
|
|||||||
_: bool = Depends(verify_admin_api_key),
|
_: bool = Depends(verify_admin_api_key),
|
||||||
):
|
):
|
||||||
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
|
"""Rebuild and cache the sitemap from current school data. Called by Airflow after data updates."""
|
||||||
global _sitemaps, _place_registry
|
global _sitemap_xml
|
||||||
# Places and sitemap are rebuilt together — they read the same marts, and
|
_sitemap_xml = build_sitemap()
|
||||||
# letting them drift apart would submit URLs for places that no longer
|
n = _sitemap_xml.count("<url>")
|
||||||
# exist.
|
return {"status": "ok", "urls": n}
|
||||||
_place_registry = None
|
|
||||||
_sitemaps = build_sitemaps()
|
|
||||||
n = sum(x.count("<url>") for x in _sitemaps.values())
|
|
||||||
return {"status": "ok", "urls": n, "sitemaps": len(_sitemaps)}
|
|
||||||
|
|
||||||
|
|
||||||
# Mount static files directly (must be after all routes to avoid catching API calls)
|
# Mount static files directly (must be after all routes to avoid catching API calls)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from .config import settings
|
|||||||
from .database import SessionLocal, engine
|
from .database import SessionLocal, engine
|
||||||
from .models import (
|
from .models import (
|
||||||
DimSchool, DimLocation, KS2Performance,
|
DimSchool, DimLocation, KS2Performance,
|
||||||
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
|
FactOfstedInspection, FactAdmissions,
|
||||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||||
)
|
)
|
||||||
from .ofsted_codes import ofsted_page_url, report_card_labels
|
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:
|
def _census_dict(pc) -> dict:
|
||||||
return {
|
return {
|
||||||
"year": pc.year,
|
"year": pc.year,
|
||||||
@@ -770,7 +759,6 @@ def _empty_supplementary() -> dict:
|
|||||||
"census": None,
|
"census": None,
|
||||||
"admissions": None,
|
"admissions": None,
|
||||||
"admissions_history": [],
|
"admissions_history": [],
|
||||||
"admission_distance": None,
|
|
||||||
"sen_detail": None,
|
"sen_detail": None,
|
||||||
"phonics": None,
|
"phonics": None,
|
||||||
"deprivation": 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
|
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
||||||
_safe(_admissions)
|
_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.
|
# Deprivation — one row per URN.
|
||||||
def _deprivation():
|
def _deprivation():
|
||||||
rows = (
|
rows = (
|
||||||
|
|||||||
@@ -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")),
|
|
||||||
}
|
|
||||||
@@ -188,37 +188,6 @@ class FactAdmissions(Base):
|
|||||||
admissions_policy = Column(String(100))
|
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):
|
class FactPupilCharacteristics(Base):
|
||||||
"""School pupil composition from EES census — one row per URN per year."""
|
"""School pupil composition from EES census — one row per URN per year."""
|
||||||
__tablename__ = "fact_pupil_characteristics"
|
__tablename__ = "fact_pupil_characteristics"
|
||||||
|
|||||||
@@ -1,204 +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, field
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Five schools with publishable data. Below this a place has nothing to say
|
|
||||||
# that a list of schools does not, and publishing it is index bloat.
|
|
||||||
MIN_SCHOOLS = 5
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Place:
|
|
||||||
kind: str # "town" | "locality" | "authority" | "outcode"
|
|
||||||
slug: str
|
|
||||||
name: str
|
|
||||||
urns: tuple[int, ...]
|
|
||||||
parent_authority: str | None # authority NAME, for the 301 target
|
|
||||||
# URNs per phase, so the per-phase threshold can be applied without
|
|
||||||
# re-querying. A place with 30 primaries and 2 secondaries publishes a
|
|
||||||
# primary variant and no secondary one.
|
|
||||||
phase_urns: dict[str, tuple[int, ...]] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def publishes_phase(self, phase: str) -> bool:
|
|
||||||
return len(self.phase_urns.get(phase, ())) >= MIN_SCHOOLS
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key(self) -> str:
|
|
||||||
return f"{self.kind}:{self.slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def _publishable_urns(df) -> set[int]:
|
|
||||||
"""URNs with something a page could state, deduplicated across years."""
|
|
||||||
from backend.app import _PUBLISHABLE_FIELDS
|
|
||||||
|
|
||||||
cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
|
|
||||||
if not cols:
|
|
||||||
return set()
|
|
||||||
return set(df.loc[df[cols].notna().any(axis=1), "urn"].astype(int))
|
|
||||||
|
|
||||||
|
|
||||||
def _phase_urns(group, publishable: set[int]) -> dict[str, tuple[int, ...]]:
|
|
||||||
"""URNs per phase. All-through schools count toward both, matching the
|
|
||||||
PHASE_GROUPS mapping the search filters already use."""
|
|
||||||
from backend.app import PHASE_GROUPS
|
|
||||||
|
|
||||||
if "phase" not in group.columns:
|
|
||||||
return {}
|
|
||||||
lowered = group["phase"].fillna("").str.lower()
|
|
||||||
out: dict[str, tuple[int, ...]] = {}
|
|
||||||
for phase in ("primary", "secondary"):
|
|
||||||
wanted = PHASE_GROUPS.get(phase, set())
|
|
||||||
subset = group[lowered.isin(wanted)]
|
|
||||||
urns = tuple(sorted({int(u) for u in subset["urn"]} & publishable))
|
|
||||||
if urns:
|
|
||||||
out[phase] = urns
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _parent_authority(group) -> str | None:
|
|
||||||
"""The most common authority in a group — the useful 301 target.
|
|
||||||
|
|
||||||
A town spanning several authorities has no single parent, so the mode is
|
|
||||||
the honest answer rather than an arbitrary first row.
|
|
||||||
"""
|
|
||||||
if "local_authority" not in group.columns:
|
|
||||||
return None
|
|
||||||
top = group["local_authority"].dropna()
|
|
||||||
return str(top.mode().iloc[0]) if not top.empty else None
|
|
||||||
|
|
||||||
|
|
||||||
def _group(df, column: str, kind: str, publishable: set[int]) -> dict[str, Place]:
|
|
||||||
"""One Place per distinct value of `column` that clears the threshold."""
|
|
||||||
from backend.app import _slugify
|
|
||||||
|
|
||||||
if column not in df.columns:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
out: dict[str, Place] = {}
|
|
||||||
for name, group in df.groupby(column, dropna=True):
|
|
||||||
name = str(name).strip()
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
|
||||||
if len(urns) < MIN_SCHOOLS:
|
|
||||||
continue
|
|
||||||
slug = _slugify(name)
|
|
||||||
if not slug:
|
|
||||||
continue
|
|
||||||
place = Place(
|
|
||||||
kind=kind, slug=slug, name=name, urns=urns,
|
|
||||||
parent_authority=_parent_authority(group) if kind == "town" else None,
|
|
||||||
phase_urns=_phase_urns(group, publishable),
|
|
||||||
)
|
|
||||||
out[place.key] = place
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# "SW11 2AA" -> "SW11". Two letters max, one or two digits, optional letter.
|
|
||||||
_OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d{1,2}[A-Z]?)\s")
|
|
||||||
|
|
||||||
|
|
||||||
def _outcode(postcode) -> str | None:
|
|
||||||
if not isinstance(postcode, str):
|
|
||||||
return None
|
|
||||||
m = _OUTCODE_RE.match(postcode.upper().strip())
|
|
||||||
return m.group(1) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def _outcode_places(df, publishable: set[int]) -> dict[str, Place]:
|
|
||||||
"""One Place per postcode district clearing the threshold.
|
|
||||||
|
|
||||||
These carry no phase variants: nobody searches "primary schools in SW11".
|
|
||||||
"""
|
|
||||||
if "postcode" not in df.columns:
|
|
||||||
return {}
|
|
||||||
working = df.assign(_oc=df["postcode"].map(_outcode))
|
|
||||||
working = working[working["_oc"].notna()]
|
|
||||||
|
|
||||||
out: dict[str, Place] = {}
|
|
||||||
for oc, group in working.groupby("_oc"):
|
|
||||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
|
||||||
if len(urns) < MIN_SCHOOLS:
|
|
||||||
continue
|
|
||||||
place = Place(kind="outcode", slug=str(oc).lower(), name=str(oc),
|
|
||||||
urns=urns, parent_authority=_parent_authority(group),
|
|
||||||
phase_urns=_phase_urns(group, publishable))
|
|
||||||
out[place.key] = place
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _locality_places(df, publishable: set[int],
|
|
||||||
town_slugs: set[str]) -> dict[str, Place]:
|
|
||||||
"""One Place per curated locality clearing the threshold."""
|
|
||||||
from backend.localities import LOCALITY_OUTCODES
|
|
||||||
|
|
||||||
if "postcode" not in df.columns:
|
|
||||||
return {}
|
|
||||||
working = df.assign(_oc=df["postcode"].map(_outcode))
|
|
||||||
|
|
||||||
out: dict[str, Place] = {}
|
|
||||||
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
|
|
||||||
if slug in town_slugs:
|
|
||||||
# Skip, do not raise. The guard exists so a locality never
|
|
||||||
# silently shadows a town — skipping achieves that, and the error
|
|
||||||
# log makes it loud.
|
|
||||||
#
|
|
||||||
# Raising here took down sitemap generation for all 25,000 school
|
|
||||||
# pages when "richmond" met the GIAS town Richmond in North
|
|
||||||
# Yorkshire. Worse, GIAS town names change without any code change,
|
|
||||||
# so a raise means curated data can break the site spontaneously.
|
|
||||||
# A curation mistake must cost one page, not the sitemap.
|
|
||||||
logger.error(
|
|
||||||
"locality %r collides with the published town of the same "
|
|
||||||
"slug and has been skipped; rename it or remove it", slug)
|
|
||||||
continue
|
|
||||||
group = working[working["_oc"].isin(outcodes)]
|
|
||||||
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
|
||||||
if len(urns) < MIN_SCHOOLS:
|
|
||||||
# Not an error — a locality can legitimately be too small. Logged
|
|
||||||
# because one you meant to publish quietly vanishing is the
|
|
||||||
# failure worth hearing about.
|
|
||||||
logger.warning(
|
|
||||||
"locality %s (%s) has %d publishable schools, below the "
|
|
||||||
"threshold of %d - not published",
|
|
||||||
slug, ", ".join(outcodes), len(urns), MIN_SCHOOLS)
|
|
||||||
continue
|
|
||||||
place = Place(kind="locality", slug=slug, name=name, urns=urns,
|
|
||||||
parent_authority=_parent_authority(group),
|
|
||||||
phase_urns=_phase_urns(group, publishable))
|
|
||||||
out[place.key] = place
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_place_registry(df) -> dict[str, Place]:
|
|
||||||
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
|
|
||||||
if df.empty or "urn" not in df.columns:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
publishable = _publishable_urns(df)
|
|
||||||
registry: dict[str, Place] = {}
|
|
||||||
registry.update(_group(df, "local_authority", "authority", publishable))
|
|
||||||
|
|
||||||
towns = _group(df, "town", "town", publishable)
|
|
||||||
registry.update(towns)
|
|
||||||
|
|
||||||
town_slugs = {p.slug for p in towns.values()}
|
|
||||||
registry.update(_locality_places(df, publishable, town_slugs))
|
|
||||||
registry.update(_outcode_places(df, publishable))
|
|
||||||
return registry
|
|
||||||
@@ -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"
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -1,290 +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]
|
|
||||||
|
|
||||||
|
|
||||||
def test_phase_variants_are_submitted_where_the_phase_clears_the_threshold(place_sitemaps):
|
|
||||||
# "primary schools in beccles" is the query shape the baseline showed, so
|
|
||||||
# each variant is its own page and has to be submitted. Emitting only the
|
|
||||||
# bare place URL left ~950 of them reachable by nothing.
|
|
||||||
xml = place_sitemaps["places-1.xml"]
|
|
||||||
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood/primary</loc>" in xml
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_phase_below_its_own_threshold_is_not_submitted(place_sitemaps):
|
|
||||||
# The fixture is six primaries and no secondaries.
|
|
||||||
xml = place_sitemaps["places-1.xml"]
|
|
||||||
assert "/schools/brentwood/secondary" not in xml
|
|
||||||
|
|
||||||
|
|
||||||
def test_outcodes_get_no_phase_variants(place_sitemaps):
|
|
||||||
# Nobody searches "primary schools in CM13"; the routes do not exist.
|
|
||||||
xml = place_sitemaps["outcodes-1.xml"]
|
|
||||||
assert "/primary" not in xml and "/secondary" not in xml
|
|
||||||
@@ -8,31 +8,8 @@ from backend import data_loader
|
|||||||
from backend.data_loader import get_supplementary_data_batch
|
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:
|
class _FakeQuery:
|
||||||
"""Records that a query ran and serves canned rows filtered by an in-list.
|
"""Records that a query ran and serves canned rows filtered by an in-list."""
|
||||||
|
|
||||||
order_by is honoured rather than ignored. The batch loader picks a row per
|
|
||||||
URN by position — first for "latest Ofsted", last for "latest cut-off
|
|
||||||
distance" — which is only correct because the database returned them
|
|
||||||
sorted. A double that drops the ORDER BY makes those picks depend on
|
|
||||||
fixture insertion order instead, so the test would pass with the sort
|
|
||||||
reversed or removed and prove nothing about the query.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, recorder, model_name, rows):
|
def __init__(self, recorder, model_name, rows):
|
||||||
self._rec = recorder
|
self._rec = recorder
|
||||||
@@ -42,20 +19,7 @@ class _FakeQuery:
|
|||||||
def filter(self, *args, **kwargs):
|
def filter(self, *args, **kwargs):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def order_by(self, *criteria):
|
def order_by(self, *args, **kwargs):
|
||||||
for crit in reversed(criteria): # reversed = stable multi-key sort
|
|
||||||
name, descending = _sort_key(crit)
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
values = [getattr(r, name, None) for r in self._rows]
|
|
||||||
# Only sort on plainly comparable values. Some fixtures stand dates
|
|
||||||
# up as namespace objects, which raise on <; leaving those in their
|
|
||||||
# given order matches what the real query would produce for them.
|
|
||||||
if not all(isinstance(v, (int, float, str)) for v in values):
|
|
||||||
continue
|
|
||||||
self._rows = sorted(
|
|
||||||
self._rows, key=lambda r: getattr(r, name), reverse=descending
|
|
||||||
)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def all(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():
|
def test_one_query_per_table_and_latest_row_per_urn():
|
||||||
rows = {
|
rows = {
|
||||||
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
# 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),
|
_ofsted_row(2, "2021-06-01", 1),
|
||||||
],
|
],
|
||||||
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
"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": [],
|
"FactPupilCharacteristics": [],
|
||||||
"FactDeprivation": [],
|
"FactDeprivation": [],
|
||||||
"FactFinance": [],
|
"FactFinance": [],
|
||||||
@@ -136,10 +85,10 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
|||||||
session = _FakeSession(rows)
|
session = _FakeSession(rows)
|
||||||
out = get_supplementary_data_batch(session, [1, 2])
|
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) == [
|
assert sorted(session.queries) == [
|
||||||
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
|
"FactAdmissions", "FactDeprivation", "FactFinance",
|
||||||
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
|
"FactOfstedInspection", "FactPupilCharacteristics",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Latest Ofsted kept per URN
|
# 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[1]["admissions"]["year"] == 202627
|
||||||
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
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
|
# Empty tables degrade to the null block, not a crash
|
||||||
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
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)
|
single = data_loader.get_supplementary_data(session, 5)
|
||||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||||
assert single["admissions_history"] == []
|
assert single["admissions_history"] == []
|
||||||
assert single["admission_distance"] is None
|
|
||||||
assert "admission_distance_history" not in single
|
|
||||||
@@ -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
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
|
- 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 (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`).
|
- 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).
|
- 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.
|
- 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
|
- Severity guess: P2
|
||||||
|
|
||||||
- **F6. Mobile hero omits the value proposition shown on desktop**
|
- **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).
|
- 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.
|
- 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)
|
- 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.
|
|
||||||
@@ -121,33 +121,6 @@ test('the home page has a real title', async ({ page }) => {
|
|||||||
expect(await page.title()).toMatch(/schoolcompare/i);
|
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 }) => {
|
test('the landing bands share one vertical rhythm', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const gaps = await page.evaluate(() => {
|
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
|
* These are silent failures — nothing on the page looks wrong — so they need
|
||||||
* a gate.
|
* 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 }) => {
|
test('the brand asset set is complete and served', async ({ page }) => {
|
||||||
const response = await page.goto('/');
|
const response = await page.goto('/');
|
||||||
expect(response?.ok()).toBe(true);
|
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.ok()).toBe(true);
|
||||||
expect(og.headers()['content-type']).toContain('image/png');
|
expect(og.headers()['content-type']).toContain('image/png');
|
||||||
|
|
||||||
/*
|
// iOS ignores SVG touch icons, so this must be a real raster.
|
||||||
* 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);
|
||||||
* 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);
|
|
||||||
expect(apple.headers()['content-type']).toContain('image/png');
|
expect(apple.headers()['content-type']).toContain('image/png');
|
||||||
|
|
||||||
// Android needs a maskable PNG or the install prompt has no icon.
|
// 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.
|
// hashed at build time and change on any unrelated edit.
|
||||||
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
||||||
|
|
||||||
/*
|
const mark = home.locator('svg').first();
|
||||||
* 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();
|
|
||||||
await expect(mark).toBeVisible();
|
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 }) => {
|
test('no visible text falls back to the browser default black', async ({ page }) => {
|
||||||
@@ -1210,678 +1055,3 @@ for (const scheme of ['light', 'dark'] as const) {
|
|||||||
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a place page links its phase variants, and they resolve', async ({ page }) => {
|
|
||||||
// "primary schools in beccles" is the query shape the baseline showed. The
|
|
||||||
// first cut submitted only the bare place URL and linked nothing, leaving
|
|
||||||
// ~950 variant pages reachable by nothing at all.
|
|
||||||
const res = await page.request.get('/api/places');
|
|
||||||
const { places } = await res.json();
|
|
||||||
const town = places.find((p: { kind: string }) => p.kind === 'town');
|
|
||||||
expect(town).toBeTruthy();
|
|
||||||
|
|
||||||
const detail = await (await page.request.get(`/api/places/town/${town.slug}`)).json();
|
|
||||||
test.skip(!(detail.place.phases ?? []).length, 'no phase clears the threshold here');
|
|
||||||
|
|
||||||
await page.goto(`/schools/${town.slug}`);
|
|
||||||
const phase = detail.place.phases[0];
|
|
||||||
const link = page.locator(`a[href="/schools/${town.slug}/${phase}"]`).first();
|
|
||||||
await expect(link).toBeVisible();
|
|
||||||
|
|
||||||
await link.click();
|
|
||||||
await expect(page.locator('h1')).toContainText(new RegExp(`${phase} schools in`, 'i'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phase variants are submitted in the places sitemap', async ({ page }) => {
|
|
||||||
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
|
|
||||||
expect(xml).toMatch(/\/schools\/[a-z0-9-]+\/primary</);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no page title repeats the brand', async ({ page }) => {
|
|
||||||
// The root layout appends '| schoolcompare' to a plain-string title. Any
|
|
||||||
// route whose title already carries the brand must opt out with
|
|
||||||
// `absolute`, or it ships '... | schoolcompare | schoolcompare' — which is
|
|
||||||
// how ~2,600 place pages first went out.
|
|
||||||
const res = await page.request.get('/api/places');
|
|
||||||
const { places } = await res.json();
|
|
||||||
const town = places.find((p: { kind: string }) => p.kind === 'town');
|
|
||||||
|
|
||||||
for (const path of ['/', '/rankings', '/admissions', `/schools/${town.slug}`]) {
|
|
||||||
await page.goto(path);
|
|
||||||
const title = await page.title();
|
|
||||||
const brands = (title.match(/schoolcompare/gi) ?? []).length;
|
|
||||||
expect(brands, `${path} repeats the brand: ${title}`).toBeLessThanOrEqual(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
@@ -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,42 +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 as { absolute: string }).absolute).toMatch(/schools in brentwood/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('canonicalises to its own path on the www host', async () => {
|
|
||||||
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
|
|
||||||
expect(m.alternates?.canonical)
|
|
||||||
.toBe('https://www.schoolcompare.co.uk/schools/brentwood');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opts out of the layout template, which would double the brand', () => {
|
|
||||||
// The root layout appends '| schoolcompare' to a plain string title, and
|
|
||||||
// these titles already carry it — every place page shipped reading
|
|
||||||
// '... | schoolcompare | schoolcompare' until this was made absolute.
|
|
||||||
return placeMeta({ params: Promise.resolve({ place: 'brentwood' }) })
|
|
||||||
.then((m) => {
|
|
||||||
expect(typeof m.title).toBe('object');
|
|
||||||
expect((m.title as { absolute: string }).absolute)
|
|
||||||
.not.toMatch(/schoolcompare.*schoolcompare/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('an unknown place gets a not-found title rather than inventing one', async () => {
|
|
||||||
const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) });
|
|
||||||
expect(m.title).toMatch(/not found/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,201 +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', phases: ['primary'] },
|
|
||||||
schools: [
|
|
||||||
{ urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
|
|
||||||
ofsted_grade: 1, phase: 'Primary' } as never,
|
|
||||||
{ urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
|
|
||||||
ofsted_grade: 3, phase: 'Primary' } as never,
|
|
||||||
],
|
|
||||||
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('PlaceView', () => {
|
|
||||||
it('leads with an H1 that matches how the place is searched', () => {
|
|
||||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
expect(screen.getByRole('heading', { level: 1 }))
|
|
||||||
.toHaveTextContent(/primary schools in brentwood/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('states the count so the page says something before the table', () => {
|
|
||||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
expect(screen.getByText(/29 schools/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('compares the local average against England, which a list cannot', () => {
|
|
||||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('63');
|
|
||||||
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('61');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('links every school in scope, which is what de-orphans them', () => {
|
|
||||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
expect(screen.getAllByRole('link', { name: /Primary$/ })).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('links to the parent authority so the place sits in a hierarchy', () => {
|
|
||||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
expect(screen.getByRole('link', { name: /Essex/i }))
|
|
||||||
.toHaveAttribute('href', '/schools/authority/essex');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the Ofsted distribution, not just a count of Outstanding', () => {
|
|
||||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
expect(screen.getByTestId('ofsted-distribution')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('links to neighbouring places so the page is not a dead end', () => {
|
|
||||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
||||||
neighbours={[{ kind: 'town', slug: 'romford', name: 'Romford', count: 40 }]} />);
|
|
||||||
expect(screen.getByRole('link', { name: /Romford/ }))
|
|
||||||
.toHaveAttribute('href', '/schools/romford');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says nothing about an average it does not have', () => {
|
|
||||||
render(<PlaceView detail={{ ...detail, averages:
|
|
||||||
{ rwm_expected_pct: null, attainment_8_score: null } }}
|
|
||||||
phase="primary" englandAverage={61} neighbours={[]} />);
|
|
||||||
expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PlaceView structured data', () => {
|
|
||||||
function jsonLd() {
|
|
||||||
const { container } = render(<PlaceView detail={detail} phase="primary"
|
|
||||||
englandAverage={61} neighbours={[]} />);
|
|
||||||
const el = container.querySelector('script[type="application/ld+json"]');
|
|
||||||
return JSON.parse(el!.textContent!);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('declares the page as a ranked list, not prose', () => {
|
|
||||||
const types = jsonLd()['@graph'].map((n: { '@type': string }) => n['@type']);
|
|
||||||
expect(types).toContain('ItemList');
|
|
||||||
expect(types).toContain('BreadcrumbList');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives every listed school an absolute URL on the canonical host', () => {
|
|
||||||
const list = jsonLd()['@graph'].find((n: { '@type': string }) => n['@type'] === 'ItemList');
|
|
||||||
expect(list.itemListElement).toHaveLength(2);
|
|
||||||
for (const item of list.itemListElement) {
|
|
||||||
expect(item.url).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\/school\//);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PlaceView phase variants', () => {
|
|
||||||
it('links the phase variants that exist', () => {
|
|
||||||
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
|
|
||||||
expect(screen.getByRole('link', { name: /Primary schools in Brentwood/i }))
|
|
||||||
.toHaveAttribute('href', '/schools/brentwood/primary');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('links no variant for a phase below its own threshold', () => {
|
|
||||||
render(<PlaceView detail={detail} englandAverage={61} neighbours={[]} />);
|
|
||||||
expect(screen.queryByRole('link', { name: /Secondary schools in Brentwood/i }))
|
|
||||||
.not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not link sideways from a variant page to itself', () => {
|
|
||||||
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
expect(screen.queryByRole('link', { name: /Primary schools in Brentwood/i }))
|
|
||||||
.not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PlaceView presentation', () => {
|
|
||||||
// /schools/brentwood shipped with 8 of 27 rows blank: an unphased page shows
|
|
||||||
// one primary-only measure for a list that also holds secondaries.
|
|
||||||
const mixed: PlaceDetail = {
|
|
||||||
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 4,
|
|
||||||
parent_authority: 'Essex', phases: ['primary', 'secondary'] },
|
|
||||||
schools: [
|
|
||||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
|
||||||
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
|
||||||
{ urn: 2, school_name: 'Beta High', phase: 'Secondary',
|
|
||||||
rwm_expected_pct: null, attainment_8_score: 47 } as never,
|
|
||||||
],
|
|
||||||
averages: { rwm_expected_pct: 63, attainment_8_score: 45 },
|
|
||||||
};
|
|
||||||
|
|
||||||
it('gives each phase its own table rather than one column of blanks', () => {
|
|
||||||
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
|
|
||||||
expect(screen.getByRole('heading', { name: /^Primary schools/ })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('heading', { name: /^Secondary schools/ })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('82%')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('47')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names the measure in plain words, not jargon', () => {
|
|
||||||
// The first cut said "RWM expected", which appears nowhere else on the site.
|
|
||||||
render(<PlaceView detail={mixed} englandAverage={61} neighbours={[]} />);
|
|
||||||
expect(screen.getByText('Reading, writing & maths')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/RWM expected/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says a missing result is unpublished rather than showing a bare dash', () => {
|
|
||||||
const noResult: PlaceDetail = {
|
|
||||||
...mixed,
|
|
||||||
schools: [{ urn: 3, school_name: 'New Primary', phase: 'Primary',
|
|
||||||
rwm_expected_pct: null, attainment_8_score: null } as never],
|
|
||||||
};
|
|
||||||
render(<PlaceView detail={noResult} englandAverage={61} neighbours={[]} />);
|
|
||||||
expect(screen.getByText('Not published')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('styles school links to the site convention rather than browser default', () => {
|
|
||||||
const { container } = render(<PlaceView detail={mixed} englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
const link = container.querySelector('a[href^="/school/"]');
|
|
||||||
expect(link?.className).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a phased page shows one table and no phase headings', () => {
|
|
||||||
render(<PlaceView detail={mixed} phase="primary" englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
expect(screen.queryByRole('heading', { name: /^Secondary schools/ }))
|
|
||||||
.not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PlaceView table alignment', () => {
|
|
||||||
const aligned: PlaceDetail = {
|
|
||||||
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 2,
|
|
||||||
parent_authority: 'Essex', phases: ['primary'] },
|
|
||||||
schools: [
|
|
||||||
{ urn: 1, school_name: 'Alpha Primary', phase: 'Primary',
|
|
||||||
rwm_expected_pct: 82, attainment_8_score: null } as never,
|
|
||||||
],
|
|
||||||
averages: { rwm_expected_pct: 63, attainment_8_score: null },
|
|
||||||
};
|
|
||||||
|
|
||||||
it('aligns the measure heading and its values with the same class', () => {
|
|
||||||
// They were aligned by two different selectors whose specificity did not
|
|
||||||
// match: `.table th:last-child` (0,2,1) won and went right, while `.num`
|
|
||||||
// (0,1,0) lost to `.table td` (0,1,1) and stayed left. Sharing one class
|
|
||||||
// is what makes them impossible to drift apart.
|
|
||||||
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
const th = container.querySelectorAll('th')[1];
|
|
||||||
const td = container.querySelectorAll('tbody td')[1];
|
|
||||||
expect(th.className).toBeTruthy();
|
|
||||||
expect(td.className).toBe(th.className);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves the school-name column unclassed so it takes the spare width', () => {
|
|
||||||
const { container } = render(<PlaceView detail={aligned} englandAverage={61}
|
|
||||||
neighbours={[]} />);
|
|
||||||
expect(container.querySelectorAll('th')[0].className).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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', () => {
|
it('omits sections with no data', () => {
|
||||||
const flags = computeSchoolFlags(specialFixture);
|
const flags = computeSchoolFlags(specialFixture);
|
||||||
const ids = buildNavItems(flags, {
|
const ids = buildNavItems(flags, {
|
||||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
expect(ids).not.toContain('ofsted');
|
expect(ids).not.toContain('ofsted');
|
||||||
@@ -74,7 +74,6 @@ describe('buildNavItems', () => {
|
|||||||
return buildNavItems(flags, {
|
return buildNavItems(flags, {
|
||||||
ofsted: fixture.ofsted,
|
ofsted: fixture.ofsted,
|
||||||
admissions: fixture.admissions,
|
admissions: fixture.admissions,
|
||||||
admissionDistance: null,
|
|
||||||
yearlyDataLength: fixture.yearlyData.length,
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
}).find((n) => n.id === 'results')?.label;
|
}).find((n) => n.id === 'results')?.label;
|
||||||
};
|
};
|
||||||
@@ -84,27 +83,11 @@ describe('buildNavItems', () => {
|
|||||||
expect(label(allThroughFixture)).toBe('Results');
|
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', () => {
|
it('keeps the engagement-led ordering', () => {
|
||||||
const flags = computeSchoolFlags(primaryFixture);
|
const flags = computeSchoolFlags(primaryFixture);
|
||||||
const ids = buildNavItems(flags, {
|
const ids = buildNavItems(flags, {
|
||||||
ofsted: primaryFixture.ofsted,
|
ofsted: primaryFixture.ofsted,
|
||||||
admissions: primaryFixture.admissions,
|
admissions: primaryFixture.admissions,
|
||||||
admissionDistance: null,
|
|
||||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
@@ -144,29 +127,16 @@ describe('buildSecondaryNavItems', () => {
|
|||||||
const ids = buildSecondaryNavItems(flags, {
|
const ids = buildSecondaryNavItems(flags, {
|
||||||
ofsted: secondaryFixture.ofsted,
|
ofsted: secondaryFixture.ofsted,
|
||||||
admissions: secondaryFixture.admissions,
|
admissions: secondaryFixture.admissions,
|
||||||
admissionDistance: null,
|
|
||||||
yearlyDataLength: secondaryFixture.yearlyData.length,
|
yearlyDataLength: secondaryFixture.yearlyData.length,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
|
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', () => {
|
it('gates History on more than one year, unlike the primary page', () => {
|
||||||
const flags = computeSecondaryFlags(secondaryFixture);
|
const flags = computeSecondaryFlags(secondaryFixture);
|
||||||
const ids = buildSecondaryNavItems(flags, {
|
const ids = buildSecondaryNavItems(flags, {
|
||||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
expect(ids).not.toContain('history');
|
expect(ids).not.toContain('history');
|
||||||
|
|||||||
@@ -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, {
|
const navItems = buildNavItems(flags, {
|
||||||
ofsted: fixture.ofsted,
|
ofsted: fixture.ofsted,
|
||||||
admissions: fixture.admissions,
|
admissions: fixture.admissions,
|
||||||
admissionDistance: fixture.admissionDistance ?? null,
|
|
||||||
yearlyDataLength: fixture.yearlyData.length,
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ export function renderSchoolDetail(fixture: any) {
|
|||||||
>
|
>
|
||||||
<PrimarySchoolSections
|
<PrimarySchoolSections
|
||||||
{...fixture}
|
{...fixture}
|
||||||
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
|
||||||
nationalAvg={nationalAveragesFixture}
|
nationalAvg={nationalAveragesFixture}
|
||||||
flags={flags}
|
flags={flags}
|
||||||
/>
|
/>
|
||||||
@@ -59,7 +57,6 @@ export function renderSecondarySchoolDetail(fixture: any) {
|
|||||||
const navItems = buildSecondaryNavItems(flags, {
|
const navItems = buildSecondaryNavItems(flags, {
|
||||||
ofsted: fixture.ofsted,
|
ofsted: fixture.ofsted,
|
||||||
admissions: fixture.admissions,
|
admissions: fixture.admissions,
|
||||||
admissionDistance: fixture.admissionDistance ?? null,
|
|
||||||
yearlyDataLength: fixture.yearlyData.length,
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,8 +70,6 @@ export function renderSecondarySchoolDetail(fixture: any) {
|
|||||||
>
|
>
|
||||||
<SecondarySchoolSections
|
<SecondarySchoolSections
|
||||||
{...fixture}
|
{...fixture}
|
||||||
admissionsHistory={fixture.admissionsHistory ?? []}
|
|
||||||
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
|
||||||
nationalAvg={nationalAveragesFixture}
|
nationalAvg={nationalAveragesFixture}
|
||||||
flags={flags}
|
flags={flags}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { absoluteUrl } from '@/lib/site';
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { AdmissionsView } from '@/components/AdmissionsView';
|
import { AdmissionsView } from '@/components/AdmissionsView';
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
// Deadlines and offer days are what gets searched, and what this page is
|
title: 'School Admissions Guide',
|
||||||
// genuinely best at — the countdowns are live.
|
|
||||||
title: { absolute: 'School Admissions Deadlines & Offer Days | schoolcompare' },
|
|
||||||
description:
|
description:
|
||||||
'Every key date for primary and secondary school admissions in England, with live countdowns to the application deadline and National Offer Day.',
|
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
|
||||||
alternates: { canonical: absoluteUrl('/admissions') },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdmissionsPage() {
|
export default function AdmissionsPage() {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import { fetchComparison, fetchMetrics } from '@/lib/api';
|
import { fetchComparison, fetchMetrics } from '@/lib/api';
|
||||||
import { ComparisonView } from '@/components/ComparisonView';
|
import { ComparisonView } from '@/components/ComparisonView';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
interface ComparePageProps {
|
interface ComparePageProps {
|
||||||
@@ -15,36 +14,13 @@ interface ComparePageProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const metadata: Metadata = {
|
||||||
* Indexability depends on the query string, so this cannot be a static export.
|
title: 'Compare Schools',
|
||||||
*
|
description:
|
||||||
* Bare /compare is the landing page for the "compare schools" head term and
|
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
||||||
* stays indexable. /compare?urns=… is an unbounded parameter space — 25,193
|
keywords:
|
||||||
* schools make ~317 million pairs — so it goes noindex. It stays `follow` and
|
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||||
* keeps a canonical to the bare path, so the links out to each school page
|
};
|
||||||
* still count.
|
|
||||||
*/
|
|
||||||
export async function generateMetadata(
|
|
||||||
{ searchParams }: ComparePageProps,
|
|
||||||
): Promise<Metadata> {
|
|
||||||
const { urns } = await searchParams;
|
|
||||||
|
|
||||||
const base: Metadata = {
|
|
||||||
// Deliberately not the homepage's phrase. Two pages chasing "compare
|
|
||||||
// schools" is how a site competes with itself; this one takes the tool
|
|
||||||
// phrasing instead.
|
|
||||||
title: 'School Comparison Tool — Up to Five at Once | schoolcompare',
|
|
||||||
description:
|
|
||||||
'Put up to five English schools in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.',
|
|
||||||
keywords:
|
|
||||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
|
||||||
alternates: { canonical: absoluteUrl('/compare') },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!urns) return base;
|
|
||||||
|
|
||||||
return { ...base, robots: { index: false, follow: true } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||||
// can still use Next.js's per-call revalidate cache.
|
// can still use Next.js's per-call revalidate cache.
|
||||||
|
|||||||
@@ -193,17 +193,13 @@
|
|||||||
drawn SVG, so its colours are in the file, not here. What is left is the
|
drawn SVG, so its colours are in the file, not here. What is left is the
|
||||||
ground the artwork sits on and fades into.
|
ground the artwork sits on and fades into.
|
||||||
|
|
||||||
--hero-ground is sampled from the artwork's own copy area, not from
|
--hero-ground is sampled from the artwork's own copy area (#FDF9F3),
|
||||||
Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
|
not from Sand. The artwork reserves an empty cream region on the left
|
||||||
seam straight down the hero.
|
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
|
||||||
The artwork's copy area is not one flat colour: it runs from a peach
|
darker per channel) leaves a visible seam straight down the hero. */
|
||||||
#FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
|
--hero-ground: #FDF9F3;
|
||||||
the left edge is foliage rather than cream. This value is sampled from
|
--hero-ground-rgb: 253, 249, 243;
|
||||||
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;
|
|
||||||
|
|
||||||
/* ── Geometry & motion ──────────────────────────────────────────────
|
/* ── Geometry & motion ──────────────────────────────────────────────
|
||||||
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Navigation } from '@/components/Navigation';
|
|||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
import { ComparisonToast } from '@/components/ComparisonToast';
|
import { ComparisonToast } from '@/components/ComparisonToast';
|
||||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
import { SITE_URL } from '@/lib/site';
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
// Manrope carries headings and key messaging — the guideline's "friendly,
|
// Manrope carries headings and key messaging — the guideline's "friendly,
|
||||||
@@ -48,32 +47,29 @@ export const metadata: Metadata = {
|
|||||||
statusBarStyle: 'default',
|
statusBarStyle: 'default',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
default: 'Compare Schools Side by Side | schoolcompare',
|
default: 'schoolcompare | Compare School Performance',
|
||||||
template: '%s | schoolcompare',
|
template: '%s | schoolcompare',
|
||||||
},
|
},
|
||||||
description:
|
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
|
||||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||||
authors: [{ name: 'schoolcompare' }],
|
authors: [{ name: 'schoolcompare' }],
|
||||||
manifest: '/manifest.json',
|
manifest: '/manifest.json',
|
||||||
// No `icons` key on purpose: setting it here would override the file
|
// 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
|
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
|
||||||
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL('https://schoolcompare.co.uk'),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: 'website',
|
||||||
title: 'Compare Schools Side by Side | schoolcompare',
|
title: 'schoolcompare | Compare School Performance',
|
||||||
description:
|
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
url: 'https://schoolcompare.co.uk',
|
||||||
url: SITE_URL,
|
|
||||||
siteName: 'schoolcompare',
|
siteName: 'schoolcompare',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
// summary_large_image now that there is an image worth showing.
|
// summary_large_image now that there is an image worth showing.
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: 'Compare Schools Side by Side | schoolcompare',
|
title: 'schoolcompare | Compare School Performance',
|
||||||
description:
|
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Main landing page with school search and browsing
|
* Main landing page with school search and browsing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { absoluteUrl } from '@/lib/site';
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
@@ -34,25 +33,8 @@ interface HomePageProps {
|
|||||||
* saying the brand twice.
|
* saying the brand twice.
|
||||||
*/
|
*/
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
/*
|
title: { absolute: 'schoolcompare | Compare every school in England' },
|
||||||
* Intent in the title, differentiator in the description.
|
description: 'Search and compare school performance across England',
|
||||||
*
|
|
||||||
* These queries are owned by the DfE's own "Compare school performance"
|
|
||||||
* service, and the old title — brand first, then a near-paraphrase of that
|
|
||||||
* service's name — gave a searcher no reason to pick us over it. It drew
|
|
||||||
* 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same
|
|
||||||
* neighbourhood, so the ranking was never the problem.
|
|
||||||
*
|
|
||||||
* The title now matches what people type. The description carries the one
|
|
||||||
* fact gov.uk does not publish: how close you had to live to get a place.
|
|
||||||
*/
|
|
||||||
title: { absolute: 'Compare Schools Side by Side | schoolcompare' },
|
|
||||||
description:
|
|
||||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
|
||||||
// This page reads eleven search params. They filter a result set; they do
|
|
||||||
// not make a new document. Collapsing every combination onto "/" stops the
|
|
||||||
// homepage competing with itself for its own head terms.
|
|
||||||
alternates: { canonical: absoluteUrl('/') },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The page reads searchParams, which makes rendering dynamic by default.
|
// The page reads searchParams, which makes rendering dynamic by default.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import { fetchRankings, fetchFilters, fetchMetrics } from '@/lib/api';
|
import { fetchRankings, fetchFilters, fetchMetrics } from '@/lib/api';
|
||||||
import { RankingsView } from '@/components/RankingsView';
|
import { RankingsView } from '@/components/RankingsView';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
interface RankingsPageProps {
|
interface RankingsPageProps {
|
||||||
@@ -18,15 +17,9 @@ interface RankingsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
// 'School Rankings' matched nothing anyone types. League tables is the
|
title: 'School Rankings',
|
||||||
// phrase parents actually search, and it spikes each results day.
|
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
||||||
title: { absolute: 'Primary & Secondary School League Tables | schoolcompare' },
|
|
||||||
description:
|
|
||||||
'Rank English schools by SATs results, GCSEs, Progress 8 or Attainment 8, and filter by local authority or year. Built from the DfE’s own figures.',
|
|
||||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
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
|
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
@@ -15,6 +14,6 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
disallow: ['/api/', '/_next/'],
|
disallow: ['/api/', '/_next/'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sitemap: absoluteUrl('/sitemap.xml'),
|
sitemap: 'https://schoolcompare.co.uk/sitemap.xml',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
} from '@/lib/schoolSections';
|
} from '@/lib/schoolSections';
|
||||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||||
import type { NationalAverages } from '@/lib/types';
|
import type { NationalAverages } from '@/lib/types';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +97,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: absoluteUrl(canonicalPath),
|
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||||
siteName: 'schoolcompare',
|
siteName: 'schoolcompare',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -107,7 +106,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: absoluteUrl(canonicalPath),
|
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -148,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
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
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
@@ -177,8 +176,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
const navInput = {
|
const navInput = {
|
||||||
ofsted: ofsted ?? null,
|
ofsted: ofsted ?? null,
|
||||||
admissions: admissions ?? null,
|
admissions: admissions ?? null,
|
||||||
admissionDistance: admission_distance ?? null,
|
|
||||||
hasLocation: school_info.latitude != null && school_info.longitude != null,
|
|
||||||
yearlyDataLength: yearly_data.length,
|
yearlyDataLength: yearly_data.length,
|
||||||
};
|
};
|
||||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||||
@@ -231,8 +228,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionsHistory={admissions_history ?? []}
|
|
||||||
admissionDistance={admission_distance ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
@@ -254,7 +249,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionsHistory={admissions_history ?? []}
|
admissionsHistory={admissions_history ?? []}
|
||||||
admissionDistance={admission_distance ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
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: { absolute: `${word} Schools in ${name} — Ranked | schoolcompare` },
|
|
||||||
description:
|
|
||||||
`Every ${phase} school in ${name} ranked by results, with Ofsted grades and `
|
|
||||||
+ `the local average against England.`,
|
|
||||||
alternates: { canonical: absoluteUrl(`/schools/${slug}/${phase}`) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PlacePhasePage({ params }: Props) {
|
|
||||||
const { place: slug, phase } = await params;
|
|
||||||
if (!isPhase(phase)) notFound();
|
|
||||||
const detail = await resolve(slug, phase);
|
|
||||||
if (!detail || detail.schools.length === 0) notFound();
|
|
||||||
|
|
||||||
const national = await fetchNationalAverages().catch(() => null);
|
|
||||||
const englandAverage = phase === 'secondary'
|
|
||||||
? national?.secondary?.attainment_8_score ?? null
|
|
||||||
: national?.primary?.rwm_expected_pct ?? null;
|
|
||||||
|
|
||||||
return <PlaceView detail={detail} phase={phase}
|
|
||||||
englandAverage={englandAverage} neighbours={[]} />;
|
|
||||||
}
|
|
||||||
@@ -1,94 +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 {
|
|
||||||
// absolute: the root layout's template appends '| schoolcompare' to a
|
|
||||||
// plain string, and this title already carries it. Without this every
|
|
||||||
// place title read '... | schoolcompare | schoolcompare'.
|
|
||||||
title: { absolute: `Schools in ${name} — Compare ${count} Schools | schoolcompare` },
|
|
||||||
description:
|
|
||||||
`Every school in ${name} ranked by SATs and GCSE results, with Ofsted grades, `
|
|
||||||
+ `the local average against England, and how close you had to live to get a place.`,
|
|
||||||
alternates: { canonical: absoluteUrl(`/schools/${slug}`) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PlacePage({ params }: Props) {
|
|
||||||
const { place: slug } = await params;
|
|
||||||
const detail = await resolve(slug);
|
|
||||||
if (!detail) notFound();
|
|
||||||
|
|
||||||
// Global constraint: no page without a local average. A place with too few
|
|
||||||
// schools carrying results has nothing to say that a list does not, so it
|
|
||||||
// defers to its authority rather than publishing a thin page.
|
|
||||||
if (detail.averages.rwm_expected_pct == null
|
|
||||||
&& detail.averages.attainment_8_score == null) {
|
|
||||||
if (detail.place.parent_authority) {
|
|
||||||
redirect(`/schools/authority/${authoritySlug(detail.place.parent_authority)}`);
|
|
||||||
}
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const national = await fetchNationalAverages().catch(() => null);
|
|
||||||
// NationalAverages is nested by phase — { primary: {...}, secondary: {...} }
|
|
||||||
// — not flat. Reading it flat silently yields undefined and the page renders
|
|
||||||
// with no comparison, which is the one thing that makes it not a list.
|
|
||||||
return (
|
|
||||||
<PlaceView
|
|
||||||
detail={detail}
|
|
||||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
|
||||||
neighbours={await neighboursOf(detail)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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: { absolute: `Schools in ${name} — Local Authority | schoolcompare` },
|
|
||||||
description:
|
|
||||||
`All ${count} schools in the ${name} local authority, ranked by SATs and GCSE `
|
|
||||||
+ `results, with Ofsted grades and the authority average against England.`,
|
|
||||||
alternates: { canonical: absoluteUrl(`/schools/authority/${la}`) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AuthorityPage({ params }: Props) {
|
|
||||||
const { la } = await params;
|
|
||||||
const detail = await fetchPlace('authority', la);
|
|
||||||
if (!detail) notFound();
|
|
||||||
|
|
||||||
const national = await fetchNationalAverages().catch(() => null);
|
|
||||||
return (
|
|
||||||
<PlaceView
|
|
||||||
detail={detail}
|
|
||||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
|
||||||
neighbours={[]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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: { absolute: `Schools near ${name} | schoolcompare` },
|
|
||||||
description:
|
|
||||||
`${count} schools in the ${name} postcode district, ranked by results, with `
|
|
||||||
+ `Ofsted grades and how close you had to live to get a place.`,
|
|
||||||
alternates: { canonical: absoluteUrl(`/schools/near/${outcode}`) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function OutcodePage({ params }: Props) {
|
|
||||||
const { outcode } = await params;
|
|
||||||
const detail = await fetchPlace('outcode', outcode);
|
|
||||||
if (!detail) notFound();
|
|
||||||
|
|
||||||
const national = await fetchNationalAverages().catch(() => null);
|
|
||||||
return (
|
|
||||||
<PlaceView
|
|
||||||
detail={detail}
|
|
||||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
|
||||||
neighbours={[]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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 dynamic = 'force-dynamic';
|
||||||
export const runtime = 'nodejs';
|
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() {
|
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}`);
|
|
||||||
}
|
|
||||||
|
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) {
|
@media (max-width: 420px) {
|
||||||
/* Below this the pin costs more room than it earns. */
|
/* Below this the pin costs more room than it earns. */
|
||||||
.heroMode .omniIcon {
|
.heroMode .omniIcon {
|
||||||
|
|||||||
@@ -92,22 +92,6 @@
|
|||||||
max-width: 34ch;
|
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 {
|
.sectionTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
|||||||
@@ -35,14 +35,6 @@ export function Footer() {
|
|||||||
<p className={styles.description}>
|
<p className={styles.description}>
|
||||||
Compare primary and secondary schools across England.
|
Compare primary and secondary schools across England.
|
||||||
</p>
|
</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
|
<a
|
||||||
href="mailto:contact@schoolcompare.co.uk"
|
href="mailto:contact@schoolcompare.co.uk"
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
|
|||||||
@@ -161,22 +161,10 @@
|
|||||||
* page and out-shouts the H1 and the search box. Dimming it in CSS is the
|
* 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
|
* whole treatment, and the scrim then fades it into the dark panel instead of
|
||||||
* into cream.
|
* 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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.heroArt img {
|
.heroArt img {
|
||||||
filter: brightness(0.75) saturate(0.72) contrast(1.02);
|
filter: brightness(0.52) saturate(0.72) contrast(1.02);
|
||||||
}
|
}
|
||||||
.heroPanel::before {
|
.heroPanel::before {
|
||||||
background: linear-gradient(
|
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 {
|
.heroEyebrow {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -302,42 +253,18 @@
|
|||||||
* the same decision.
|
* the same decision.
|
||||||
*/
|
*/
|
||||||
@media (max-width: 860px) {
|
@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 {
|
.heroContent {
|
||||||
padding: 1.75rem 1.5rem 1.75rem;
|
padding: 2rem 1.5rem 1.75rem;
|
||||||
max-width: none;
|
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 {
|
.heroArt {
|
||||||
position: static;
|
position: static;
|
||||||
order: -1;
|
height: 11rem;
|
||||||
height: 13rem;
|
|
||||||
}
|
}
|
||||||
/* The band crop puts the schoolhouse at 73% across — reported by
|
/* The band crop centres the schoolhouse at about 65% across, so squeezing
|
||||||
scripts/build-hero-images.js, which derives it from the crop box rather
|
toward 4.9:1 crops the empty sides rather than the subject. */
|
||||||
than leaving the two to drift. Squeezing narrower then crops the emptier
|
|
||||||
left side rather than the subject. */
|
|
||||||
.heroArt img {
|
.heroArt img {
|
||||||
object-position: 73% center;
|
object-position: 65% center;
|
||||||
}
|
}
|
||||||
/* The scrim exists to protect text sitting on the artwork. Nothing sits on
|
/* The scrim exists to protect text sitting on the artwork. Nothing sits on
|
||||||
it here, and leaving it would wash the band out. */
|
it here, and leaving it would wash the band out. */
|
||||||
@@ -378,12 +305,8 @@
|
|||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
margin-bottom: 0.5rem;
|
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 {
|
.heroArt {
|
||||||
height: 10rem;
|
height: 8.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,20 +157,7 @@ const VALUE_PROPS: ValueProp[] = [
|
|||||||
{
|
{
|
||||||
icon: <ShieldCheckIcon />,
|
icon: <ShieldCheckIcon />,
|
||||||
tintClass: styles.propIconTrust,
|
tintClass: styles.propIconTrust,
|
||||||
/*
|
title: 'Official & trusted',
|
||||||
* "Built on official data", never "Official".
|
|
||||||
*
|
|
||||||
* The previous title was "Official & trusted", whose grammatical subject
|
|
||||||
* is this site — it reads as a claim that schoolcompare is itself an
|
|
||||||
* official service. It is not: it is an independent site that republishes
|
|
||||||
* official figures. The distinction is the difference between describing
|
|
||||||
* the data and describing ourselves, and only the first is true.
|
|
||||||
*
|
|
||||||
* Everywhere else the word appears ("official DfE figures", "the official
|
|
||||||
* figure isn't in") it already qualifies the data, which is correct and
|
|
||||||
* should stay.
|
|
||||||
*/
|
|
||||||
title: 'Built on official data',
|
|
||||||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -463,6 +450,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
geoState={geoState}
|
geoState={geoState}
|
||||||
geoError={geoError}
|
geoError={geoError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
|
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. */
|
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
|
||||||
const WIDE_W = 2000;
|
const WIDE_W = 1672;
|
||||||
const WIDE_H = 1125;
|
const WIDE_H = 941;
|
||||||
|
|
||||||
export function HeroIllustration() {
|
export function HeroIllustration() {
|
||||||
return (
|
return (
|
||||||
@@ -51,13 +51,13 @@ export function HeroIllustration() {
|
|||||||
<source
|
<source
|
||||||
media="(max-width: 860px)"
|
media="(max-width: 860px)"
|
||||||
type="image/avif"
|
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}
|
sizes={SIZES}
|
||||||
/>
|
/>
|
||||||
<source
|
<source
|
||||||
media="(max-width: 860px)"
|
media="(max-width: 860px)"
|
||||||
type="image/webp"
|
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}
|
sizes={SIZES}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
@@ -71,15 +71,15 @@ export function HeroIllustration() {
|
|||||||
media query does, and it sits after the modern formats so they still
|
media query does, and it sits after the modern formats so they still
|
||||||
win where supported.
|
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
|
<source
|
||||||
type="image/avif"
|
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}
|
sizes={SIZES}
|
||||||
/>
|
/>
|
||||||
<source
|
<source
|
||||||
type="image/webp"
|
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}
|
sizes={SIZES}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
@@ -90,7 +90,7 @@ export function HeroIllustration() {
|
|||||||
*/}
|
*/}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src="/brand/hero-wide-1400.jpg"
|
src="/brand/hero-wide-1200.jpg"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
width={WIDE_W}
|
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: '© <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%' }} />;
|
|
||||||
}
|
|
||||||
@@ -48,32 +48,13 @@
|
|||||||
Each bar compares against its own benchmark (expected vs higher standard /
|
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
|
greater depth), so the marker sits on the individual bar's track rather than
|
||||||
as one line spanning both bars. */
|
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 {
|
.natTick {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -3px;
|
top: -3px;
|
||||||
bottom: -3px;
|
bottom: -3px;
|
||||||
width: 3px;
|
width: 2px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--bg-card);
|
background: var(--brand);
|
||||||
box-shadow: 0 0 0 1px var(--text-primary);
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -82,14 +63,13 @@
|
|||||||
.natTick::before {
|
.natTick::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -3px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 6px;
|
width: 5px;
|
||||||
height: 6px;
|
height: 5px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--bg-card);
|
background: var(--brand);
|
||||||
box-shadow: 0 0 0 1px var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.barHeaderRight {
|
.barHeaderRight {
|
||||||
@@ -142,24 +122,12 @@
|
|||||||
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
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 {
|
.barExpected {
|
||||||
background: var(--brand);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barExceeding {
|
.barExceeding {
|
||||||
background: var(--brand-stronger);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barLabel {
|
.barLabel {
|
||||||
|
|||||||
@@ -143,40 +143,17 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
|||||||
<SubjectColumn key={subject.name} subject={subject} />
|
<SubjectColumn key={subject.name} subject={subject} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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.legend}>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand)' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
||||||
Expected standard
|
Expected standard
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<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
|
Exceeding / high score
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div
|
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||||
className={styles.legendSwatch}
|
|
||||||
style={{
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
boxShadow: '0 0 0 1px var(--text-primary)',
|
|
||||||
width: '3px',
|
|
||||||
height: '12px',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
National average
|
National average
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
/* Tokens only — see globals.css. Follows RankingsView's conventions, and in
|
|
||||||
particular its link treatment: table links take --text-primary with no
|
|
||||||
underline and a brand-coloured hover, not the browser default. The first
|
|
||||||
cut used bare <Link> with no class at all, which rendered as default blue
|
|
||||||
underlined links and read as unstyled beside the rest of the site. */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Links in running copy: brand colour, underline on hover only. */
|
|
||||||
.inlineLink {
|
|
||||||
color: var(--brand);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineLink:hover {
|
|
||||||
color: var(--brand-strong);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Phase variants are separate indexable pages, so the bare place page has to
|
|
||||||
link them — a sitemap entry alone leaves them with no internal path in. */
|
|
||||||
.phaseLinks {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem 0.75rem;
|
|
||||||
margin: 0 0 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phaseLink {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.4rem 0.875rem;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: border-color 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phaseLink:hover {
|
|
||||||
border-color: var(--brand);
|
|
||||||
color: var(--brand-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.groupHeading {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.625rem;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.groupCount {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Header and value share one class and one rule, so they cannot drift apart.
|
|
||||||
*
|
|
||||||
* The first cut aligned them with two different selectors: `.table th:last-child`
|
|
||||||
* at (0,2,1) beat the element rule and went right, while `.num` at (0,1,0) lost
|
|
||||||
* to `.table td` at (0,1,1) and stayed left. The heading and its numbers sat on
|
|
||||||
* opposite edges of the column.
|
|
||||||
*
|
|
||||||
* width:1% with nowrap makes the measure column hug its content so the school
|
|
||||||
* name takes the remaining width — without it the two columns split evenly and
|
|
||||||
* the gap between heading and value reads as misalignment on a wide screen.
|
|
||||||
*/
|
|
||||||
.table th.num,
|
|
||||||
.table td.num {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
width: 1%;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The measure is spelled out; the tooltip carries the definition. */
|
|
||||||
.metricHead {
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: help;
|
|
||||||
border-bottom: 1px dotted var(--border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table links: site convention is body colour, brand on hover. */
|
|
||||||
.schoolLink {
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolLink:hover {
|
|
||||||
color: var(--brand-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "Not published" is a fact about the school, not an error. */
|
|
||||||
.noData {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,229 +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 type { School } from '@/lib/types';
|
|
||||||
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'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Column headings, taken from the site's own metric dictionary rather than
|
|
||||||
* invented here — see METRIC_DEFINITIONS in backend/schemas.py, surfaced at
|
|
||||||
* /api/metrics. The first cut said "RWM expected", which is jargon that
|
|
||||||
* appears nowhere else on the site.
|
|
||||||
*/
|
|
||||||
const METRICS = {
|
|
||||||
primary: {
|
|
||||||
key: 'rwm_expected_pct' as const,
|
|
||||||
heading: 'Reading, writing & maths',
|
|
||||||
hint: '% meeting the expected standard in reading, writing and maths',
|
|
||||||
unit: '%',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
key: 'attainment_8_score' as const,
|
|
||||||
heading: 'Attainment 8',
|
|
||||||
hint: "Average grade across a pupil's best 8 GCSEs, including English and maths",
|
|
||||||
unit: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
type PhaseKey = keyof typeof METRICS;
|
|
||||||
|
|
||||||
/** All-through schools sit in both phases, matching the search filters. */
|
|
||||||
function isPhase(school: School, phase: PhaseKey): boolean {
|
|
||||||
const p = (school.phase ?? '').toLowerCase();
|
|
||||||
if (p === 'all-through') return true;
|
|
||||||
return phase === 'secondary'
|
|
||||||
? p.includes('secondary') || p === '16 plus'
|
|
||||||
: p.includes('primary') || p.includes('middle');
|
|
||||||
}
|
|
||||||
|
|
||||||
function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey }) {
|
|
||||||
const metric = METRICS[phase];
|
|
||||||
return (
|
|
||||||
<div className={styles.tableWrap}>
|
|
||||||
<table className={styles.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">School</th>
|
|
||||||
{/* Same class as the value cell below: one rule aligns both, so
|
|
||||||
they cannot drift apart. */}
|
|
||||||
<th scope="col" className={styles.num}>
|
|
||||||
<abbr className={styles.metricHead} title={metric.hint}>
|
|
||||||
{metric.heading}
|
|
||||||
</abbr>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{schools.map((s) => {
|
|
||||||
const value = s[metric.key];
|
|
||||||
return (
|
|
||||||
<tr key={s.urn}>
|
|
||||||
<td>
|
|
||||||
<Link href={schoolUrl(s.urn, s.school_name)} className={styles.schoolLink}>
|
|
||||||
{s.school_name}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className={styles.num}>
|
|
||||||
{value == null
|
|
||||||
? <span className={styles.noData}>Not published</span>
|
|
||||||
: `${Math.round(Number(value))}${metric.unit}`}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) {
|
|
||||||
const { place, schools, averages } = detail;
|
|
||||||
const local = averages[METRICS[phase ?? 'primary'].key];
|
|
||||||
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);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* An unphased page holds both primaries and secondaries, and they are
|
|
||||||
* scored on different measures — a percentage and a 0-90 score. Showing one
|
|
||||||
* column for both left 30% of rows blank on /schools/brentwood and put two
|
|
||||||
* incomparable scales in one column when it did not.
|
|
||||||
*
|
|
||||||
* So the phases get a table each. A blank cell inside one now means the
|
|
||||||
* school genuinely has no published result, which is worth saying.
|
|
||||||
*/
|
|
||||||
const groups: Array<[PhaseKey, School[]]> = phase
|
|
||||||
? [[phase, schools]]
|
|
||||||
: (['primary', 'secondary'] as PhaseKey[])
|
|
||||||
.map((p) => [p, schools.filter((s) => isPhase(s, p))] as [PhaseKey, School[]])
|
|
||||||
.filter(([, list]) => list.length > 0);
|
|
||||||
|
|
||||||
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)}`}
|
|
||||||
className={styles.inlineLink}>
|
|
||||||
{place.parent_authority}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{!phase && (place.phases ?? []).length > 0 && (
|
|
||||||
<nav className={styles.phaseLinks} aria-label="By phase">
|
|
||||||
{(place.phases ?? []).map((ph) => (
|
|
||||||
<Link key={ph} href={`/schools/${place.slug}/${ph}`} className={styles.phaseLink}>
|
|
||||||
{ph === 'secondary' ? 'Secondary schools' : 'Primary schools'} in {place.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{groups.map(([p, list]) => (
|
|
||||||
<section key={p} className={styles.group}>
|
|
||||||
{groups.length > 1 && (
|
|
||||||
<h2 className={styles.groupHeading}>
|
|
||||||
{p === 'secondary' ? 'Secondary schools' : 'Primary schools'}
|
|
||||||
<span className={styles.groupCount}>{list.length}</span>
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
<SchoolTable schools={list} phase={p} />
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{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)} className={styles.inlineLink}>{n.name}</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,128 +9,62 @@
|
|||||||
* AdmissionsViewToggle client island, which receives both views as
|
* AdmissionsViewToggle client island, which receives both views as
|
||||||
* server-rendered children. When there is only one year of offer data no
|
* server-rendered children. When there is only one year of offer data no
|
||||||
* toggle renders at all, so such pages ship zero admissions JavaScript.
|
* 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 { ReactNode } from 'react';
|
||||||
import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
import type { SchoolAdmissions } from '@/lib/types';
|
||||||
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
|
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
||||||
import { AdmissionsTrendChart } from './charts';
|
import { AdmissionsTrendChart } from './charts';
|
||||||
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
|
||||||
|
|
||||||
|
|
||||||
export function AdmissionsSection({
|
export function AdmissionsSection({
|
||||||
admissions,
|
admissions,
|
||||||
admissionsHistory,
|
admissionsHistory,
|
||||||
admissionDistance,
|
|
||||||
isAllThrough,
|
isAllThrough,
|
||||||
}: {
|
}: {
|
||||||
/* Nullable: the two data sources are independent. A minority of schools have
|
admissions: SchoolAdmissions;
|
||||||
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;
|
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
|
||||||
isAllThrough: boolean;
|
isAllThrough: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cutoff = describeCutoff(admissionDistance);
|
|
||||||
|
|
||||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
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
|
// Banded interpretation of the first-choice offer rate ("More than half of
|
||||||
// first choices missed out" etc.) — the same banding the compare screen
|
// first choices missed out" etc.) — the same banding the compare screen
|
||||||
// uses, so a low offer rate reads as how severe it actually is.
|
// 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 = (
|
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>;
|
||||||
<>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`}</>
|
|
||||||
);
|
|
||||||
|
|
||||||
{/* All-through admissions data covers a single entry point (usually the
|
{/* All-through admissions data covers a single entry point (usually the
|
||||||
Year 7 secondary intake), not reception — say so, or a parent could
|
Year 7 secondary intake), not reception — say so, or a parent could
|
||||||
read these as the whole-school figures. */}
|
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}>
|
<p className={styles.sectionSubtitle}>
|
||||||
These figures are for {admissions.school_phase.toLowerCase()} entry
|
These figures are for {admissions.school_phase.toLowerCase()} entry
|
||||||
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
||||||
</p>
|
</p>
|
||||||
) : null;
|
) : 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 = (
|
const yearView = (
|
||||||
<>
|
<>
|
||||||
<dl className={tilesClass}>
|
<dl className={styles.admissionsTiles}>
|
||||||
{admissions?.places_offered != null && (
|
{admissions.places_offered != null && (
|
||||||
<div className={styles.admissionsTile}>
|
<div className={styles.admissionsTile}>
|
||||||
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.first_preference_applications != null && (
|
{admissions.first_preference_applications != null && (
|
||||||
<div className={styles.admissionsTile}>
|
<div className={styles.admissionsTile}>
|
||||||
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.first_preference_offer_pct != null && (
|
{admissions.first_preference_offer_pct != null && (
|
||||||
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
||||||
<dd className={styles.admissionsTileNum}>
|
<dd className={styles.admissionsTileNum}>
|
||||||
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
{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>
|
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.total_applications != null && (
|
{admissions.total_applications != null && (
|
||||||
<div className={styles.admissionsTile}>
|
<div className={styles.admissionsTile}>
|
||||||
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{distanceTile}
|
|
||||||
</dl>
|
</dl>
|
||||||
{admissionsSummary?.chip && (
|
{admissionsSummary.chip && (
|
||||||
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
||||||
)}
|
)}
|
||||||
{distanceNote}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
{/* Guarded rather than asserted: showAdmissionsTrend already requires
|
const trendView = (
|
||||||
admissions, and tying the two together in one expression keeps that
|
|
||||||
invariant checked by the compiler instead of assumed. */}
|
|
||||||
const trendView = admissions && (
|
|
||||||
<>
|
<>
|
||||||
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
||||||
<AdmissionsTrendChart history={admissionsHistory} />
|
<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 (
|
return (
|
||||||
<Section id="admissions">
|
<Section id="admissions">
|
||||||
{views.length > 1 ? (
|
{showAdmissionsTrend ? (
|
||||||
<AdmissionsViewToggle title={title} subtitle={subtitle} views={views} />
|
<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}>
|
<div className={styles.admissionsHeader}>
|
||||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||||
|
|||||||
@@ -3,25 +3,14 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import styles from './schoolSections.module.css';
|
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
|
* The only interactive part of the primary admissions section, and the only
|
||||||
* client component in components/school/.
|
* client component in components/school/.
|
||||||
*
|
*
|
||||||
* Every view is always present in the DOM and visibility is toggled with the
|
* Both views are always present in the DOM and visibility is toggled with the
|
||||||
* `hidden` attribute, so the server-rendered markup passed in as content never
|
* `hidden` attribute — matching the previous behaviour exactly — so the
|
||||||
* ships as client JavaScript — this component carries the state and nothing
|
* server-rendered markup passed in as yearView/trendView never ships as client
|
||||||
* else.
|
* JavaScript.
|
||||||
*
|
*
|
||||||
* It spans the header and the viewport because the segmented control sits
|
* It spans the header and the viewport because the segmented control sits
|
||||||
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
|
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
|
||||||
@@ -30,38 +19,39 @@ export interface AdmissionsView {
|
|||||||
export function AdmissionsViewToggle({
|
export function AdmissionsViewToggle({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
views,
|
trendLabel,
|
||||||
|
yearView,
|
||||||
|
trendView,
|
||||||
}: {
|
}: {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
subtitle: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.admissionsHeader}>
|
<div className={styles.admissionsHeader}>
|
||||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||||
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
||||||
{views.map((v) => (
|
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
|
||||||
<button
|
This year
|
||||||
key={v.id}
|
</button>
|
||||||
type="button"
|
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
|
||||||
aria-pressed={active === v.id}
|
{trendLabel}
|
||||||
onClick={() => setActive(v.id)}
|
</button>
|
||||||
>
|
|
||||||
{v.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
<div className={styles.admissionsViewport}>
|
<div className={styles.admissionsViewport}>
|
||||||
{views.map((v) => (
|
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>
|
||||||
<div key={v.id} className={v.className} hidden={active !== v.id}>
|
{yearView}
|
||||||
{v.content}
|
</div>
|
||||||
</div>
|
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>
|
||||||
))}
|
{trendView}
|
||||||
|
</div>
|
||||||
</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 {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SchoolFlags } from '@/lib/schoolSections';
|
import type { SchoolFlags } from '@/lib/schoolSections';
|
||||||
import { OfstedSection } from './OfstedSection';
|
import { OfstedSection } from './OfstedSection';
|
||||||
import { ResultsSection } from './ResultsSection';
|
import { ResultsSection } from './ResultsSection';
|
||||||
import { AdmissionsSection } from './AdmissionsSection';
|
import { AdmissionsSection } from './AdmissionsSection';
|
||||||
import { DistanceSection } from './DistanceSection';
|
|
||||||
import { InclusionSection } from './InclusionSection';
|
import { InclusionSection } from './InclusionSection';
|
||||||
import { HistorySection } from './HistorySection';
|
import { HistorySection } from './HistorySection';
|
||||||
import { SchoolLifeSection } from './SchoolLifeSection';
|
import { SchoolLifeSection } from './SchoolLifeSection';
|
||||||
@@ -35,7 +34,6 @@ export interface PrimarySchoolSectionsProps {
|
|||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
@@ -44,8 +42,7 @@ export interface PrimarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function PrimarySchoolSections({
|
export function PrimarySchoolSections({
|
||||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||||
admissions, admissionsHistory, admissionDistance,
|
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
|
||||||
deprivation, finance, nationalAvg, flags,
|
|
||||||
}: PrimarySchoolSectionsProps) {
|
}: PrimarySchoolSectionsProps) {
|
||||||
const primaryAvg = nationalAvg?.primary ?? {};
|
const primaryAvg = nationalAvg?.primary ?? {};
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
@@ -94,23 +91,14 @@ export function PrimarySchoolSections({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Either source is enough to justify the section. The cut-off distance
|
{admissions && (
|
||||||
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) && (
|
|
||||||
<AdmissionsSection
|
<AdmissionsSection
|
||||||
admissions={admissions}
|
admissions={admissions}
|
||||||
admissionsHistory={admissionsHistory}
|
admissionsHistory={admissionsHistory}
|
||||||
admissionDistance={admissionDistance}
|
|
||||||
isAllThrough={flags.isAllThrough}
|
isAllThrough={flags.isAllThrough}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Its own section, directly after Admissions: it answers the question
|
|
||||||
the tile above raises. */}
|
|
||||||
<DistanceSection admissionDistance={admissionDistance} schoolInfo={schoolInfo} />
|
|
||||||
|
|
||||||
{flags.hasInclusionData && (
|
{flags.hasInclusionData && (
|
||||||
<InclusionSection
|
<InclusionSection
|
||||||
latestResults={flags.latestResults}
|
latestResults={flags.latestResults}
|
||||||
|
|||||||
@@ -6,26 +6,17 @@
|
|||||||
* JavaScript at all. Server component.
|
* 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 { formatPercentage } from '@/lib/utils';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
import {
|
|
||||||
describeCutoff, describeCutoffAbsence,
|
|
||||||
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
|
|
||||||
} from './lastDistanceOffered';
|
|
||||||
|
|
||||||
export function SecondaryAdmissionsSection({
|
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
|
admissions: SchoolAdmissions;
|
||||||
published cut-off and no EES admissions row. */
|
|
||||||
admissions: SchoolAdmissions | null;
|
|
||||||
admissionsHistory: SchoolAdmissions[];
|
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
|
||||||
schoolInfo: School;
|
schoolInfo: School;
|
||||||
hasSixthForm: boolean;
|
hasSixthForm: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cutoff = describeCutoff(admissionDistance);
|
|
||||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||||
const admissionsTag = (() => {
|
const admissionsTag = (() => {
|
||||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||||
@@ -49,43 +40,32 @@ export function SecondaryAdmissionsSection({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{admissions?.places_offered != null && (
|
{admissions.places_offered != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Year 7 places offered</div>
|
<div className={styles.metricLabel}>Year 7 places offered</div>
|
||||||
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.total_applications != null && (
|
{admissions.total_applications != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Total applications</div>
|
<div className={styles.metricLabel}>Total applications</div>
|
||||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.first_preference_applications != null && (
|
{admissions.first_preference_applications != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>1st preference applications</div>
|
<div className={styles.metricLabel}>1st preference applications</div>
|
||||||
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.first_preference_offer_pct != null && (
|
{admissions.first_preference_offer_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Families who got their first choice</div>
|
<div className={styles.metricLabel}>Families who got their first choice</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
||||||
</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>
|
</div>
|
||||||
{admissions?.oversubscribed != null && (
|
{admissions.oversubscribed != null && (
|
||||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||||
{admissions.oversubscribed
|
{admissions.oversubscribed
|
||||||
? '⚠ Applications exceeded places last year'
|
? '⚠ Applications exceeded places last year'
|
||||||
@@ -93,24 +73,9 @@ export function SecondaryAdmissionsSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Replaces a blanket "distance cut-off data is not available for this
|
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||||
school", which was hardcoded onto every secondary page and was untrue
|
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
||||||
wherever the local authority does publish. The absence is now stated
|
</p>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasSixthForm && (
|
{hasSixthForm && (
|
||||||
<div className={styles.sixthFormNote}>
|
<div className={styles.sixthFormNote}>
|
||||||
|
|||||||
@@ -13,14 +13,13 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||||
import { OfstedSection } from './OfstedSection';
|
import { OfstedSection } from './OfstedSection';
|
||||||
import { GcseSection } from './GcseSection';
|
import { GcseSection } from './GcseSection';
|
||||||
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
|
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
|
||||||
import { DistanceSection } from './DistanceSection';
|
|
||||||
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
import { SecondaryHistorySection } from './SecondaryHistorySection';
|
||||||
import { WellbeingSection } from './WellbeingSection';
|
import { WellbeingSection } from './WellbeingSection';
|
||||||
import { FinancesSection } from './FinancesSection';
|
import { FinancesSection } from './FinancesSection';
|
||||||
@@ -33,10 +32,6 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | 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;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
@@ -45,8 +40,7 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function SecondarySchoolSections({
|
export function SecondarySchoolSections({
|
||||||
schoolInfo, yearlyData, ofsted, census,
|
schoolInfo, yearlyData, ofsted, census,
|
||||||
admissions, admissionsHistory, admissionDistance,
|
admissions, deprivation, finance, nationalAvg, flags,
|
||||||
deprivation, finance, nationalAvg, flags,
|
|
||||||
}: SecondarySchoolSectionsProps) {
|
}: SecondarySchoolSectionsProps) {
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
@@ -85,21 +79,14 @@ export function SecondarySchoolSections({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* See PrimarySchoolSections: distance and EES admissions are independent
|
{admissions && (
|
||||||
sources, so either one warrants the section. */}
|
|
||||||
{(admissions || admissionDistance) && (
|
|
||||||
<SecondaryAdmissionsSection
|
<SecondaryAdmissionsSection
|
||||||
admissions={admissions}
|
admissions={admissions}
|
||||||
admissionDistance={admissionDistance}
|
|
||||||
admissionsHistory={admissionsHistory}
|
|
||||||
schoolInfo={schoolInfo}
|
schoolInfo={schoolInfo}
|
||||||
hasSixthForm={flags.hasSixthForm}
|
hasSixthForm={flags.hasSixthForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* See PrimarySchoolSections — same placement, same reason. */}
|
|
||||||
<DistanceSection admissionDistance={admissionDistance} schoolInfo={schoolInfo} />
|
|
||||||
|
|
||||||
{yearlyData.length > 1 && (
|
{yearlyData.length > 1 && (
|
||||||
<SecondaryHistorySection
|
<SecondaryHistorySection
|
||||||
yearlyData={yearlyData}
|
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;
|
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 {
|
.metricHint {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1485,62 +1453,6 @@
|
|||||||
color: var(--brand-strong);
|
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 {
|
.admissionsVerdict {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
@@ -1627,23 +1539,8 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/* Stack both views in one grid cell so the card sizes to the taller view —
|
||||||
* Stack both views in one grid cell so the card sizes to the taller view —
|
toggling modes never shifts layout. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
.admissionsViewport {
|
.admissionsViewport {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@@ -1671,20 +1568,6 @@
|
|||||||
grid-template-rows: 1fr 1fr;
|
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 {
|
.admissionsChartCap {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1924,23 +1807,12 @@
|
|||||||
border-radius: 4px 0 0 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
transition: width 0.6s ease;
|
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 {
|
.att8VizNatLine {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
bottom: -4px;
|
bottom: -4px;
|
||||||
width: 3px;
|
width: 2px;
|
||||||
background: var(--bg-card);
|
background: var(--brand);
|
||||||
box-shadow: 0 0 0 1px var(--text-primary);
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
@@ -2079,281 +1951,3 @@
|
|||||||
.secondaryScope .ofstedReportLink { margin-left: 0; display: block; margin-top: 0.25rem; }
|
.secondaryScope .ofstedReportLink { margin-left: 0; display: block; margin-top: 0.25rem; }
|
||||||
.secondaryScope .admissionsTypeBadge { font-size: 0.75rem; }
|
.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);
|
|
||||||
}
|
|
||||||
@@ -1,60 +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;
|
|
||||||
/** Phases that clear the threshold on their own, so the page links
|
|
||||||
* variants that exist rather than 404s. Absent on the registry listing. */
|
|
||||||
phases?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, SchoolCensus,
|
School, SchoolResult, AbsenceData, SchoolCensus,
|
||||||
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
|
OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { isSpecialSchool } from './utils';
|
import { isSpecialSchool } from './utils';
|
||||||
|
|
||||||
@@ -119,13 +119,6 @@ export function computeSchoolFlags({
|
|||||||
export interface NavItemsInput {
|
export interface NavItemsInput {
|
||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
admissions: SchoolAdmissions | 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;
|
yearlyDataLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +133,7 @@ export interface NavItemsInput {
|
|||||||
*/
|
*/
|
||||||
export function buildNavItems(
|
export function buildNavItems(
|
||||||
flags: SchoolFlags,
|
flags: SchoolFlags,
|
||||||
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
|
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
|
||||||
): NavItem[] {
|
): NavItem[] {
|
||||||
const navItems: NavItem[] = [];
|
const navItems: NavItem[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
@@ -150,10 +143,7 @@ export function buildNavItems(
|
|||||||
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
|
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (admissionDistance?.distance_m != null && hasLocation) {
|
|
||||||
navItems.push({ id: 'distance', label: 'Distance' });
|
|
||||||
}
|
|
||||||
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||||
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||||
@@ -223,15 +213,12 @@ export function computeSecondaryFlags({
|
|||||||
*/
|
*/
|
||||||
export function buildSecondaryNavItems(
|
export function buildSecondaryNavItems(
|
||||||
flags: SecondaryFlags,
|
flags: SecondaryFlags,
|
||||||
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
|
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
|
||||||
): NavItem[] {
|
): NavItem[] {
|
||||||
const navItems: NavItem[] = [];
|
const navItems: NavItem[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||||
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (admissionDistance?.distance_m != null && hasLocation) {
|
|
||||||
navItems.push({ id: 'distance', label: 'Distance' });
|
|
||||||
}
|
|
||||||
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||||
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
@@ -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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 {
|
export interface SchoolDeprivation {
|
||||||
lsoa_code: string | null;
|
lsoa_code: string | null;
|
||||||
idaci_score: number | null;
|
idaci_score: number | null;
|
||||||
@@ -354,14 +330,6 @@ export interface SchoolDetailsResponse {
|
|||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
||||||
admissions_history: SchoolAdmissions[];
|
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;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export function formatAcademicYear(year: number | null | undefined): string {
|
||||||
if (year == null) return '';
|
if (year == null) return '';
|
||||||
const s = year.toString();
|
const s = year.toString();
|
||||||
|
|||||||
@@ -55,37 +55,6 @@ const nextConfig = {
|
|||||||
// Headers for caching and security
|
// Headers for caching and security
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
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*',
|
source: '/:path*',
|
||||||
headers: [
|
headers: [
|
||||||
|
|||||||
@@ -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.
|
with an empty cream area on the left reserved for the headline.
|
||||||
|
|
||||||
- **Source:** supplied by the project owner
|
- **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
|
the derivatives can be regenerated
|
||||||
- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these
|
- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these
|
||||||
- **Licence:** **to be confirmed by the project owner**
|
- **Licence:** **to be confirmed by the project owner**
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -45,24 +45,14 @@ const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png');
|
|||||||
const OUT = path.join(__dirname, '..', 'public', 'brand');
|
const OUT = path.join(__dirname, '..', 'public', 'brand');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Band crop, in source pixels, measured against the artwork rather than
|
* Band crop, in source pixels. The schoolhouse occupies roughly x 1180–1540,
|
||||||
* guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
|
* y 300–520, so this keeps it whole with headroom above the flag and enough
|
||||||
* up at y 330.
|
* hillside below to read as landscape rather than as a cutout.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
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 WIDE_WIDTHS = [1672, 1200, 900];
|
||||||
const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100);
|
const BAND_WIDTHS = [972, 700, 500];
|
||||||
|
|
||||||
const WIDE_WIDTHS = [2000, 1400, 1000];
|
|
||||||
const BAND_WIDTHS = [1344, 900, 600];
|
|
||||||
|
|
||||||
async function emit(pipeline, name, width) {
|
async function emit(pipeline, name, width) {
|
||||||
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
|
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
|
||||||
@@ -77,26 +67,25 @@ async function main() {
|
|||||||
|
|
||||||
const meta = await sharp(SRC).metadata();
|
const meta = await sharp(SRC).metadata();
|
||||||
console.log(`source ${meta.width}×${meta.height}`);
|
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);
|
const wide = sharp(SRC);
|
||||||
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w);
|
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
|
// Fallback for the <img> itself. Flattened onto the artwork's own cream so a
|
||||||
// JPEG (no alpha) can never composite against black.
|
// JPEG (no alpha) can never composite against black.
|
||||||
await sharp(SRC)
|
await sharp(SRC)
|
||||||
.resize({ width: 1400 })
|
.resize({ width: 1200 })
|
||||||
.flatten({ background: '#FEF2E1' })
|
.flatten({ background: '#FDF9F3' })
|
||||||
.jpeg({ quality: 82, mozjpeg: true })
|
.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);
|
const band = sharp(SRC).extract(BAND);
|
||||||
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
|
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
|
||||||
await sharp(SRC)
|
await sharp(SRC)
|
||||||
.extract(BAND)
|
.extract(BAND)
|
||||||
.resize({ width: 900 })
|
.resize({ width: 700 })
|
||||||
.flatten({ background: '#FEF2E1' })
|
.flatten({ background: '#FDF9F3' })
|
||||||
.jpeg({ quality: 82, mozjpeg: true })
|
.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();
|
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|||||||
@@ -224,68 +224,3 @@ with DAG(
|
|||||||
)
|
)
|
||||||
|
|
||||||
extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci
|
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
|
|
||||||