Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2208ad93c1 | ||
|
|
24f3cb4c65 | ||
|
|
3816b92d06 | ||
|
|
51ce2d6373 | ||
|
|
5ddc7314fd | ||
|
|
8ad2070e76 | ||
|
|
3aad5101a8 | ||
|
|
c47fe38971 | ||
|
|
8f211577c8 | ||
|
|
69f2201244 | ||
|
|
e6048c9ca6 | ||
|
|
7650b16f62 | ||
|
|
9abd020967 | ||
|
|
228eb214f5 | ||
|
|
ea5249a2ea | ||
|
|
ffe7e04951 | ||
|
|
c9a1892bfb | ||
|
|
50b599a09b | ||
|
|
94151c58ea | ||
|
|
8bf6145a73 | ||
|
|
a72323874f |
No files matched your search
@@ -8,3 +8,6 @@ pipeline/transform/.user.yml
|
|||||||
|
|
||||||
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Playwright run artefacts written when the suite is run from the repo root
|
||||||
|
test-results/
|
||||||
+147
-46
@@ -7,6 +7,7 @@ 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
|
||||||
@@ -48,11 +49,14 @@ PHASE_GROUPS: dict[str, set[str]] = {
|
|||||||
"all-through": {"all-through"},
|
"all-through": {"all-through"},
|
||||||
}
|
}
|
||||||
|
|
||||||
BASE_URL = "https://schoolcompare.co.uk"
|
# Must match SITE_URL in nextjs-app/lib/site.ts. The apex 301s to www, and a
|
||||||
|
# sitemap <loc> that redirects wastes a crawl on every URL it lists.
|
||||||
|
BASE_URL = "https://www.schoolcompare.co.uk"
|
||||||
MAX_SLUG_LENGTH = 60
|
MAX_SLUG_LENGTH = 60
|
||||||
|
|
||||||
# In-memory sitemap cache
|
# In-memory sitemap cache: name -> XML. Populated on startup and by the admin
|
||||||
_sitemap_xml: str | None = None
|
# regenerate endpoint after a pipeline run.
|
||||||
|
_sitemaps: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def _slugify(text: str) -> str:
|
def _slugify(text: str) -> str:
|
||||||
@@ -70,43 +74,128 @@ def _school_url(urn: int, school_name: str) -> str:
|
|||||||
return f"/school/{urn}-{slug}"
|
return f"/school/{urn}-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def build_sitemap() -> str:
|
# Routes worth submitting that are not a school page. /admissions was missing
|
||||||
"""Generate sitemap XML from in-memory school data. Returns the XML string."""
|
# from the sitemap entirely despite being a static, indexable guide.
|
||||||
df = load_school_data()
|
STATIC_SITEMAP_PATHS = ("/", "/rankings", "/compare", "/admissions")
|
||||||
|
|
||||||
static_urls = [
|
|
||||||
(BASE_URL + "/", "daily", "1.0"),
|
|
||||||
(BASE_URL + "/rankings", "weekly", "0.8"),
|
|
||||||
(BASE_URL + "/compare", "weekly", "0.8"),
|
|
||||||
]
|
|
||||||
|
|
||||||
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
|
def _has_publishable_data(row) -> bool:
|
||||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
"""True when a school page has something a search result could state.
|
||||||
|
|
||||||
for url, freq, priority in static_urls:
|
A school with no results in any year and no Ofsted grade renders an empty
|
||||||
lines.append(
|
page. Submitting it spends crawl budget and drags the corpus-wide quality
|
||||||
f" <url><loc>{url}</loc>"
|
signal down, so it stays out of the sitemap. The page itself still resolves
|
||||||
f"<changefreq>{freq}</changefreq>"
|
for anyone who has the URL.
|
||||||
f"<priority>{priority}</priority></url>"
|
"""
|
||||||
)
|
for field in ("rwm_expected_pct", "attainment_8_score", "ofsted_grade"):
|
||||||
|
value = row.get(field)
|
||||||
|
if value is not None and not pd.isna(value):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
if not df.empty and "urn" in df.columns and "school_name" in df.columns:
|
|
||||||
seen = set()
|
def _url_element(loc: str, lastmod: str | None = None) -> str:
|
||||||
for _, row in df[["urn", "school_name"]].drop_duplicates(subset="urn").iterrows():
|
"""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()
|
||||||
|
|
||||||
|
# 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"])
|
urn = int(row["urn"])
|
||||||
name = str(row["school_name"])
|
|
||||||
if urn in seen:
|
if urn in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(urn)
|
seen.add(urn)
|
||||||
path = _school_url(urn, name)
|
if not _has_publishable_data(row):
|
||||||
lines.append(
|
continue
|
||||||
f" <url><loc>{BASE_URL}{path}</loc>"
|
|
||||||
f"<changefreq>monthly</changefreq>"
|
|
||||||
f"<priority>0.6</priority></url>"
|
|
||||||
)
|
|
||||||
|
|
||||||
lines.append("</urlset>")
|
lastmod = None
|
||||||
return "\n".join(lines)
|
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 _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 build_sitemaps() -> dict[str, str]:
|
||||||
|
"""Build the sitemap index and every child, keyed by name."""
|
||||||
|
df = load_school_data()
|
||||||
|
|
||||||
|
children: dict[str, str] = {
|
||||||
|
"static.xml": _urlset(
|
||||||
|
[_url_element(BASE_URL + path) for path in STATIC_SITEMAP_PATHS]),
|
||||||
|
}
|
||||||
|
|
||||||
|
school_rows = _school_sitemap_rows(df)
|
||||||
|
# Always emit at least one school child, so the index shape is stable even
|
||||||
|
# on an empty database.
|
||||||
|
chunks = [school_rows[i:i + SITEMAP_CHUNK_SIZE]
|
||||||
|
for i in range(0, len(school_rows), SITEMAP_CHUNK_SIZE)] or [[]]
|
||||||
|
for n, chunk in enumerate(chunks, start=1):
|
||||||
|
children[f"schools-{n}.xml"] = _urlset(chunk)
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
|
||||||
|
|
||||||
|
def build_sitemap() -> str:
|
||||||
|
"""The sitemap index. Kept for `lifespan` and the admin endpoint."""
|
||||||
|
return build_sitemaps()["sitemap.xml"]
|
||||||
|
|
||||||
|
|
||||||
def clean_filter_values(series: pd.Series) -> list[str]:
|
def clean_filter_values(series: pd.Series) -> list[str]:
|
||||||
@@ -284,7 +373,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 _sitemap_xml
|
global _sitemaps
|
||||||
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:
|
||||||
@@ -294,9 +383,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:
|
||||||
_sitemap_xml = build_sitemap()
|
_sitemaps = build_sitemaps()
|
||||||
n = _sitemap_xml.count("<url>")
|
n = sum(x.count("<url>") for x in _sitemaps.values())
|
||||||
print(f"Sitemap built: {n} URLs.")
|
print(f"Sitemaps built: {len(_sitemaps)} files, {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}")
|
||||||
|
|
||||||
@@ -1087,16 +1176,28 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/sitemap.xml")
|
def _serve_sitemap(name: str) -> Response:
|
||||||
async def sitemap_xml():
|
global _sitemaps
|
||||||
"""Serve sitemap.xml for search engine indexing."""
|
if _sitemaps is None:
|
||||||
global _sitemap_xml
|
|
||||||
if _sitemap_xml is None:
|
|
||||||
try:
|
try:
|
||||||
_sitemap_xml = build_sitemap()
|
_sitemaps = build_sitemaps()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
|
raise HTTPException(status_code=503, detail=f"Sitemap unavailable: {e}")
|
||||||
return Response(content=_sitemap_xml, media_type="application/xml")
|
if name not in _sitemaps:
|
||||||
|
raise HTTPException(status_code=404, detail="No such sitemap")
|
||||||
|
return Response(content=_sitemaps[name], media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sitemap.xml")
|
||||||
|
async def sitemap_xml():
|
||||||
|
"""Serve the sitemap index."""
|
||||||
|
return _serve_sitemap("sitemap.xml")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sitemaps/{name}")
|
||||||
|
async def sitemap_child(name: str):
|
||||||
|
"""Serve a child sitemap (static.xml, or schools-N.xml)."""
|
||||||
|
return _serve_sitemap(name)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/regenerate-sitemap")
|
@app.post("/api/admin/regenerate-sitemap")
|
||||||
@@ -1106,10 +1207,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 _sitemap_xml
|
global _sitemaps
|
||||||
_sitemap_xml = build_sitemap()
|
_sitemaps = build_sitemaps()
|
||||||
n = _sitemap_xml.count("<url>")
|
n = sum(x.count("<url>") for x in _sitemaps.values())
|
||||||
return {"status": "ok", "urls": n}
|
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)
|
||||||
|
|||||||
+10
-5
@@ -849,12 +849,17 @@ 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 — latest year per URN.
|
# Last distance offered — the latest year per URN, and only that.
|
||||||
#
|
#
|
||||||
# Only the most recent year is served. Coverage is ragged (a school may
|
# The mart holds every published year and the DAG keeps loading them; what
|
||||||
# have 2021 and 2026 but nothing between), so a history array would invite
|
# changed is what leaves this process. Earlier years are being held back as
|
||||||
# a trend line drawn through gaps that are absences of publication rather
|
# a paid feature, and this API is public and unauthenticated — serving the
|
||||||
# than absences of a cut-off.
|
# 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():
|
def _admission_distance():
|
||||||
rows = (
|
rows = (
|
||||||
db.query(FactAdmissionDistance)
|
db.query(FactAdmissionDistance)
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""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.
|
||||||
|
for name, xml in sitemaps.items():
|
||||||
|
assert "https://www.schoolcompare.co.uk" in xml, name
|
||||||
|
assert "https://schoolcompare.co.uk" not 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
|
||||||
@@ -8,8 +8,31 @@ 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
|
||||||
@@ -19,7 +42,20 @@ class _FakeQuery:
|
|||||||
def filter(self, *args, **kwargs):
|
def filter(self, *args, **kwargs):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def order_by(self, *args, **kwargs):
|
def order_by(self, *criteria):
|
||||||
|
for crit in reversed(criteria): # reversed = stable multi-key sort
|
||||||
|
name, descending = _sort_key(crit)
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
values = [getattr(r, name, None) for r in self._rows]
|
||||||
|
# Only sort on plainly comparable values. Some fixtures stand dates
|
||||||
|
# up as namespace objects, which raise on <; leaving those in their
|
||||||
|
# given order matches what the real query would produce for them.
|
||||||
|
if not all(isinstance(v, (int, float, str)) for v in values):
|
||||||
|
continue
|
||||||
|
self._rows = sorted(
|
||||||
|
self._rows, key=lambda r: getattr(r, name), reverse=descending
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def all(self):
|
def all(self):
|
||||||
@@ -115,9 +151,13 @@ 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: latest year only, per URN. Coverage is ragged, so
|
# Cut-off distance: the latest year only. Earlier years stay in the mart
|
||||||
# serving anything but the newest would show a parent a stale figure.
|
# 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 out[1]["admission_distance"]["year"] == 2026
|
||||||
|
assert "admission_distance_history" not in out[1]
|
||||||
assert out[1]["admission_distance"]["distance_m"] == 529.47
|
assert out[1]["admission_distance"]["distance_m"] == 529.47
|
||||||
# route_count travels with the figure — the page needs it to say the
|
# 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.
|
# distance is the furthest of several bands rather than the only one.
|
||||||
@@ -133,3 +173,4 @@ def test_single_wrapper_matches_batch(monkeypatch):
|
|||||||
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 single["admission_distance"] is None
|
||||||
|
assert "admission_distance_history" not in single
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,309 @@
|
|||||||
|
# SEO Programme — Design
|
||||||
|
|
||||||
|
Date: 2026-08-20
|
||||||
|
Status: awaiting review
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
schoolcompare ranks second for "school compare" — an exact match for the
|
||||||
|
brand and the domain. It ranks poorly for "compare schools", "school
|
||||||
|
comparison" and "schools near me". The first is a naming artefact and
|
||||||
|
transfers to nothing; the rest are the queries that actually carry parent
|
||||||
|
demand.
|
||||||
|
|
||||||
|
The cause is structural, not editorial. The site publishes five route
|
||||||
|
families:
|
||||||
|
|
||||||
|
/ /compare /rankings /admissions /school/[slug]
|
||||||
|
|
||||||
|
Location intent has no landing page at all. Every competitor outranking us
|
||||||
|
on those queries wins with programmatic location pages:
|
||||||
|
|
||||||
|
| Competitor | URL pattern |
|
||||||
|
|---------------|--------------------------------------------|
|
||||||
|
| School Guide | `/best-schools-in/manchester` |
|
||||||
|
| Locrating | `/the-best-primary-schools-in-Manchester_…`|
|
||||||
|
| FindMySchool | `/best-primary-schools/manchester` |
|
||||||
|
| Snobe | `/best-primary-schools/manchester` |
|
||||||
|
| School Atlas | `/guides/best-primary-schools-manchester` |
|
||||||
|
|
||||||
|
"Schools near me" is a local-intent query. Google resolves it against the
|
||||||
|
user's coordinates and serves pages that are *about a place*. A national
|
||||||
|
homepage cannot win it. No title or description change fixes this; only
|
||||||
|
pages Google can localise will.
|
||||||
|
|
||||||
|
## Baseline (measured 2026-08-20, production API)
|
||||||
|
|
||||||
|
| Measure | Value |
|
||||||
|
|--------------------------------------------|---------|
|
||||||
|
| Unique schools | 27,229 |
|
||||||
|
| URLs in sitemap.xml | 27,232 |
|
||||||
|
| Schools with 2024/25 performance data | 21,266 |
|
||||||
|
| Schools with **no** current performance data| ~5,963 (22%) |
|
||||||
|
| Welsh establishments (all metrics null) | 1,569 |
|
||||||
|
| Overseas / offshore establishments | 467 |
|
||||||
|
| Static URLs in sitemap | 3 |
|
||||||
|
| Routes setting a canonical | 1 of 5 |
|
||||||
|
|
||||||
|
Three findings from that table drive the plan.
|
||||||
|
|
||||||
|
**We submit ~6,000 thin pages to Google.** `build_sitemap()`
|
||||||
|
(`backend/app.py:73`) enumerates every URN regardless of whether the school
|
||||||
|
has any data. Welsh establishments return `school_type: "Welsh
|
||||||
|
establishment"` with every performance metric, Ofsted grade and phase field
|
||||||
|
null. Overseas and offshore establishments ("BFPO Overseas Establishments",
|
||||||
|
"Gibraltar Overseas Establishments", "Jersey Offshore Establishments") are
|
||||||
|
in the local-authority list too. At 22% of the submitted corpus this is a
|
||||||
|
site-wide quality signal problem and a crawl-budget waste, not a rounding
|
||||||
|
error.
|
||||||
|
|
||||||
|
W1 item 4 removes 2,036 of those — every non-England establishment — taking
|
||||||
|
the corpus to 25,193. The 3,927 that remain are English schools with no
|
||||||
|
current data: mostly newly opened, special, nursery or alternative provision.
|
||||||
|
Those are a template problem, not a corpus problem, and item 5 handles them
|
||||||
|
separately.
|
||||||
|
|
||||||
|
**The homepage is its own competitor.** `app/page.tsx` accepts eleven search
|
||||||
|
params (`search`, `local_authority`, `school_type`, `phase`, `page`,
|
||||||
|
`postcode`, `radius`, `sort`, `gender`, `admissions_policy`,
|
||||||
|
`has_sixth_form`) and sets no canonical. Every filter combination is a
|
||||||
|
crawlable near-duplicate of the single page we are asking to rank for
|
||||||
|
"compare schools".
|
||||||
|
|
||||||
|
**School pages are near-orphans.** Reachable from the sitemap and from site
|
||||||
|
search, but almost nothing links to them contextually, so they accrue no
|
||||||
|
internal authority.
|
||||||
|
|
||||||
|
Also noted: the sitemap emits invented `priority` values and no `lastmod`.
|
||||||
|
Google ignores `priority` and `changefreq` entirely; `lastmod` is the field
|
||||||
|
it does read, and we omit it.
|
||||||
|
|
||||||
|
## Keyword clusters
|
||||||
|
|
||||||
|
Ranked by judgement of UK parent search behaviour and by the competitive
|
||||||
|
SERP evidence above. Google Search Console is connected, so cluster
|
||||||
|
priorities are to be re-derived from measured impressions before build
|
||||||
|
starts (see Workstream 0).
|
||||||
|
|
||||||
|
**C1 — Head "compare" terms.** compare schools · school comparison · school
|
||||||
|
comparison tool · compare school performance · compare primary schools ·
|
||||||
|
compare secondary schools · compare two schools
|
||||||
|
|
||||||
|
**C2 — League tables and rankings.** primary school league tables ·
|
||||||
|
secondary school league tables · school league tables 2026 · SATs results by
|
||||||
|
school · GCSE results by school · KS2 league tables · Progress 8 rankings ·
|
||||||
|
best primary schools in [town] · top 10 primary schools in [LA]
|
||||||
|
|
||||||
|
**C3 — Local / near me.** schools near me · primary schools near me ·
|
||||||
|
secondary schools near me · best schools near me · good schools near me ·
|
||||||
|
schools in [town] · primary schools in [LA] · schools near [postcode] ·
|
||||||
|
[postcode] school catchment
|
||||||
|
|
||||||
|
**C4 — Individual school long tail.** [school] ofsted · [school] SATs
|
||||||
|
results · [school] catchment area · [school] reviews · [school] URN
|
||||||
|
|
||||||
|
**C5 — Admissions.** primary school admissions 2027 · national offer day
|
||||||
|
2027 · school application deadline · school admissions appeal ·
|
||||||
|
oversubscription criteria · distance criteria school admissions · didn't get
|
||||||
|
first choice school · school admissions [LA]
|
||||||
|
|
||||||
|
**C6 — Metric explainers.** what is a good SATs score · what is Progress 8 ·
|
||||||
|
what is Attainment 8 · expected standard KS2 meaning · scaled score
|
||||||
|
explained · Ofsted grades explained · Ofsted report cards · pupil premium
|
||||||
|
explained
|
||||||
|
|
||||||
|
**C7 — Head to head.** [school A] vs [school B] · academy vs community
|
||||||
|
school · grammar school vs comprehensive · faith school vs community school
|
||||||
|
|
||||||
|
## Workstreams
|
||||||
|
|
||||||
|
### W0 — Measure before touching anything
|
||||||
|
|
||||||
|
Export a Google Search Console baseline: impressions, clicks, average
|
||||||
|
position and CTR by query and by page, for the trailing 16 months. Bucket
|
||||||
|
queries into C1–C7. This sets the counterfactual — without it, no later
|
||||||
|
claim about lift is defensible, because school-search traffic is strongly
|
||||||
|
seasonal (results day in December, offer day in March/April).
|
||||||
|
|
||||||
|
Re-rank C1–C7 against measured impressions and adjust the sequence below if
|
||||||
|
the data disagrees with the judgement calls.
|
||||||
|
|
||||||
|
### W1 — Crawl hygiene and index sanity
|
||||||
|
|
||||||
|
Cheap, and it unblocks everything after it. Adding 5,000 pages on top of a
|
||||||
|
corpus that is 22% thin would compound the existing problem.
|
||||||
|
|
||||||
|
1. Canonical on every route. `/`, `/rankings`, `/compare` and `/admissions`
|
||||||
|
currently set none.
|
||||||
|
2. The homepage canonicalises to `/` regardless of search params.
|
||||||
|
3. `/compare?urns=…` gets `noindex, follow` — it is an unbounded parameter
|
||||||
|
space with no standalone value.
|
||||||
|
4. **England only — DONE.** Wales, the Crown Dependencies, Gibraltar and the
|
||||||
|
service/overseas schools are removed from the corpus at the mart boundary,
|
||||||
|
not hidden at the view layer. `dim_school` and `dim_location` both exclude
|
||||||
|
`TypeOfEstablishment` in {25, 26, 30, 37} — Offshore schools, Service
|
||||||
|
children's education, Welsh establishment, British schools overseas —
|
||||||
|
listed once as `vars.non_england_school_type_codes` in `dbt_project.yml`.
|
||||||
|
That removes 2,036 establishments and 29 local authorities, and because
|
||||||
|
`build_sitemap()` reads the same marts, it drops them from the sitemap in
|
||||||
|
the same stroke. `assert_england_only_schools` fails the pipeline if a GIAS
|
||||||
|
refresh reintroduces them or if the two models drift apart.
|
||||||
|
5. Prune the remaining thin pages: exclude any school with no performance data
|
||||||
|
**and** no Ofsted record. Distinct from item 4 — these are English schools
|
||||||
|
with nothing yet to show, so the fix may be a better template rather than
|
||||||
|
removal.
|
||||||
|
6. Rebuild the sitemap as a sitemap **index**: one child per page family,
|
||||||
|
real `lastmod` from the data-load timestamp, `priority` and `changefreq`
|
||||||
|
dropped.
|
||||||
|
|
||||||
|
### W2 — The location layer
|
||||||
|
|
||||||
|
The dominant lever. `dim_location` already carries `town`, `county`,
|
||||||
|
`local_authority_name`, `parliamentary_constituency`, `latitude`,
|
||||||
|
`longitude` and `postcode`, so no new ingestion is required.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
/schools/[la] e.g. /schools/manchester
|
||||||
|
/schools/[la]/primary
|
||||||
|
/schools/[la]/secondary
|
||||||
|
/best-primary-schools/[town]
|
||||||
|
/best-secondary-schools/[town]
|
||||||
|
/schools/near/[outcode] e.g. /schools/near/m20
|
||||||
|
/schools/near-me geolocating hub
|
||||||
|
|
||||||
|
**Thin-page threshold: generate a town or outcode page only where at least
|
||||||
|
five schools have current performance data.** Below that, 301 to the parent
|
||||||
|
LA page. This is the single most important constraint in the workstream —
|
||||||
|
it is what separates a location layer from index bloat.
|
||||||
|
|
||||||
|
Each page must earn its place with content a parent would actually use, not
|
||||||
|
a template shell:
|
||||||
|
|
||||||
|
- H1 matching the query intent ("Best primary schools in Manchester")
|
||||||
|
- Counts framed usefully: "137 primary schools, 9 rated Outstanding"
|
||||||
|
- A ranked table of the top 20 on the headline metric
|
||||||
|
- Local average against the England average
|
||||||
|
- Ofsted grade distribution
|
||||||
|
- Map
|
||||||
|
- Links to neighbouring towns and to the parent LA
|
||||||
|
- An FAQ block (feeds `FAQPage` in W4)
|
||||||
|
- Links to every school page in scope — this is what de-orphans W1's corpus
|
||||||
|
|
||||||
|
Sizing estimate: ~150 usable LAs × 3 ≈ 450; towns clearing the threshold
|
||||||
|
≈ 1,200 × 2 ≈ 2,400; outcodes ≈ 2,300. Roughly **5,000 new pages**,
|
||||||
|
comfortably inside a sitemap index and well under the per-file 50,000 limit.
|
||||||
|
|
||||||
|
### W3 — Make rankings indexable
|
||||||
|
|
||||||
|
`/rankings` is driven entirely by query params, so Google indexes
|
||||||
|
approximately one page where there should be hundreds.
|
||||||
|
|
||||||
|
/rankings/[phase]/[metric]
|
||||||
|
/rankings/[phase]/[metric]/[la]
|
||||||
|
|
||||||
|
The interactive filter UI stays; its state moves into real paths. Param
|
||||||
|
forms canonicalise to the clean path. This is the direct play for C2.
|
||||||
|
|
||||||
|
### W4 — Structured data and internal linking
|
||||||
|
|
||||||
|
- Replace the bare `EducationalOrganization` on school pages with `School`,
|
||||||
|
and populate it properly.
|
||||||
|
- `BreadcrumbList` site-wide.
|
||||||
|
- `ItemList` on every rankings and location page.
|
||||||
|
- `FAQPage` on admissions and on location pages.
|
||||||
|
- New school-page modules: "Other schools in [town]", "Nearby schools",
|
||||||
|
"Compare with similar schools". Each links out to W2 and W3 pages, which
|
||||||
|
is what circulates authority instead of stranding it.
|
||||||
|
|
||||||
|
Explicitly **not** doing `Dataset` or `AggregateRating` — no review corpus
|
||||||
|
exists, and fabricating one would be both useless and a policy violation.
|
||||||
|
|
||||||
|
### W5 — Admissions expansion
|
||||||
|
|
||||||
|
One static page currently carries an entire cluster.
|
||||||
|
|
||||||
|
/admissions/[la] per-authority deadlines and offer day
|
||||||
|
/admissions/appeals
|
||||||
|
/admissions/national-offer-day
|
||||||
|
|
||||||
|
`school admissions [LA]` is high-intent and highly seasonal; per-authority
|
||||||
|
pages are the natural unit.
|
||||||
|
|
||||||
|
### W6 — Explainer content
|
||||||
|
|
||||||
|
/guides/progress-8
|
||||||
|
/guides/attainment-8
|
||||||
|
/guides/sats-scaled-scores
|
||||||
|
/guides/ofsted-grades
|
||||||
|
/guides/expected-standard
|
||||||
|
|
||||||
|
Each links into the corresponding W3 rankings page. Cheap to build, and it
|
||||||
|
is what gives the metric vocabulary enough topical weight to support C1–C4.
|
||||||
|
|
||||||
|
### W7 — Head-to-head pages
|
||||||
|
|
||||||
|
/compare/[school-a]-vs-[school-b]
|
||||||
|
|
||||||
|
C7 is uncontested and native to the product. It is also the easiest way to
|
||||||
|
destroy everything W1 fixes: 27,229 schools generate 370 million pairs.
|
||||||
|
**Curated pairs only** — same town, both with current data, both with real
|
||||||
|
search demand — capped in the low thousands. Gated behind evidence that W2
|
||||||
|
is indexing cleanly.
|
||||||
|
|
||||||
|
### W8 — Metadata rewrite for C1
|
||||||
|
|
||||||
|
Current homepage title is `schoolcompare | Compare every school in England`,
|
||||||
|
which spends the most valuable position on the brand. Rewrite the homepage,
|
||||||
|
rankings and compare titles and descriptions around C1 phrasing. Small
|
||||||
|
change, and the cheapest item in the programme.
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
W0 → W1 → W2 → W3 → W4 → W5 → W6 → (W7 if W2 indexes cleanly)
|
||||||
|
|
||||||
|
W1 before W2 is not negotiable: adding pages to a corpus that is 22% thin
|
||||||
|
compounds the problem rather than diluting it.
|
||||||
|
|
||||||
|
This spec is a programme, not a single implementation plan. Each workstream
|
||||||
|
gets its own plan and its own PR; W2 will likely need several. Only W0 and W1
|
||||||
|
are ready to plan against today — the rest should be re-read after W0's
|
||||||
|
Search Console baseline lands, because that data may reorder them.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Per CLAUDE.md, user-facing behaviour changes extend the `e2e/` journeys in
|
||||||
|
the same PR. Each workstream adds:
|
||||||
|
|
||||||
|
- W1: canonical present and correct on every route; `/compare?urns=` carries
|
||||||
|
`noindex`; sitemap excludes a known dataless URN.
|
||||||
|
- W2: a known LA, town and outcode page renders with the expected school
|
||||||
|
count; a below-threshold town redirects to its LA.
|
||||||
|
- W3: a clean rankings path renders; the param form canonicalises to it.
|
||||||
|
- W4: JSON-LD parses and validates against the declared types.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
**Index bloat.** The failure mode of every programmatic SEO programme. The
|
||||||
|
five-school threshold, the W1 prune and the W7 gate are the three controls.
|
||||||
|
|
||||||
|
**Helpful-content exposure.** Google's stance on templated location pages
|
||||||
|
has hardened. The mitigation is that each page carries genuinely local
|
||||||
|
computed data — real counts, real distributions, real local-vs-national
|
||||||
|
comparison — rather than a name substituted into boilerplate.
|
||||||
|
|
||||||
|
**Build cost.** School pages already use ISR with a 7-day revalidate and
|
||||||
|
`PRERENDER_SCHOOLS` gating full prerender. 5,000 more routes need the same
|
||||||
|
treatment; full static generation of 32,000 pages is likely impractical in
|
||||||
|
CI.
|
||||||
|
|
||||||
|
**Seasonality.** Results day and offer day dominate the traffic curve.
|
||||||
|
Judging the programme on a mid-summer window would misread it in either
|
||||||
|
direction. W0's baseline must be year-on-year, not month-on-month.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. Catchment areas are Locrating's moat and a strong C3 driver
|
||||||
|
(`[postcode] school catchment`). `fact_admissions` carries admission
|
||||||
|
distances. Is deriving approximate catchment a later workstream, or out
|
||||||
|
of scope?
|
||||||
+391
-3
@@ -1231,10 +1231,9 @@ async function schoolWithCutoff(page: Page) {
|
|||||||
const res = await page.request.get(`/api/schools/${urn}`);
|
const res = await page.request.get(`/api/schools/${urn}`);
|
||||||
if (!res.ok()) continue;
|
if (!res.ok()) continue;
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body?.admission_distance?.distance_m != null) {
|
if (body?.admission_distance?.distance_m == null) continue;
|
||||||
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
|
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1253,7 +1252,11 @@ test('a published cut-off distance is shown with the year it belongs to', async
|
|||||||
await expect(label).toContainText(`September ${found!.distance.year}`);
|
await expect(label).toContainText(`September ${found!.distance.year}`);
|
||||||
|
|
||||||
// And the figure itself, in the unit councils publish in.
|
// And the figure itself, in the unit councils publish in.
|
||||||
await expect(page.getByText(/\d+(\.\d+)? (miles|m)\b/).first()).toBeVisible();
|
// 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 }) => {
|
test('a cut-off distance is never shown without saying it is not a catchment', async ({ page }) => {
|
||||||
@@ -1307,3 +1310,388 @@ test('no school page shows an implausible cut-off distance', async ({ page }) =>
|
|||||||
}
|
}
|
||||||
test.skip(checked === 0, 'no cut-off distances available to check yet');
|
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');
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* The postcode check.
|
||||||
|
*
|
||||||
|
* This is the one place on the site that answers a question about a specific
|
||||||
|
* family rather than about a school, so the tests here are mostly about what it
|
||||||
|
* refuses to say — and that matters more now than it did, because there is only
|
||||||
|
* one year to answer with. A run of years used to soften a single close call;
|
||||||
|
* nothing does now, so the "too close to call" band is the whole safety margin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { CutoffMapPanel } from '@/components/school/CutoffMapPanel';
|
||||||
|
import { CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered';
|
||||||
|
import type { School, SchoolAdmissionDistance } from '@/lib/types';
|
||||||
|
|
||||||
|
// Leaflet needs a real layout box and network tiles; neither exists in jsdom.
|
||||||
|
jest.mock('@/components/LeafletCutoffMapInner', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div data-testid="cutoff-map" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGeocode = jest.fn();
|
||||||
|
jest.mock('@/lib/api', () => ({
|
||||||
|
...jest.requireActual('@/lib/api'),
|
||||||
|
geocodePostcode: (pc: string) => mockGeocode(pc),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School;
|
||||||
|
|
||||||
|
const cutoff = (distance_m: number | null, year = 2026): SchoolAdmissionDistance => ({
|
||||||
|
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A point due north of the school, `metres` away. 1° latitude ≈ 111,320 m. */
|
||||||
|
function northOf(metres: number) {
|
||||||
|
return { latitude: SCHOOL.latitude! + metres / 111_320, longitude: SCHOOL.longitude! };
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPanel = (c = cutoff(800)) =>
|
||||||
|
render(<CutoffMapPanel schoolInfo={SCHOOL} cutoff={c} />);
|
||||||
|
|
||||||
|
async function check(postcode: string) {
|
||||||
|
fireEvent.change(screen.getByLabelText('Your postcode'), { target: { value: postcode } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => mockGeocode.mockReset());
|
||||||
|
|
||||||
|
describe('CutoffMapPanel', () => {
|
||||||
|
it('renders nothing without a figure to compare against', () => {
|
||||||
|
const { container } = renderPanel(cutoff(null));
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when the school has no coordinates', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CutoffMapPanel
|
||||||
|
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
|
||||||
|
cutoff={cutoff(800)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed postcode without calling the geocoder', async () => {
|
||||||
|
renderPanel();
|
||||||
|
await check('not a postcode');
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/);
|
||||||
|
expect(mockGeocode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the year in the verdict, so the figure is never free-floating', async () => {
|
||||||
|
mockGeocode.mockResolvedValue(northOf(200));
|
||||||
|
renderPanel(cutoff(800, 2026));
|
||||||
|
await check('SE23 3NA');
|
||||||
|
|
||||||
|
const result = await screen.findByRole('status');
|
||||||
|
expect(result).toHaveTextContent(/inside the/);
|
||||||
|
expect(result).toHaveTextContent(/September 2026/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a home clearly beyond the cut-off', async () => {
|
||||||
|
mockGeocode.mockResolvedValue(northOf(5000));
|
||||||
|
renderPanel(cutoff(800));
|
||||||
|
await check('SE23 3NA');
|
||||||
|
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(/beyond the/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declines to call a result that sits inside the measurement error', async () => {
|
||||||
|
// Nominally inside the 800 m cut-off, but by half the uncertainty band —
|
||||||
|
// which a postcode centroid cannot resolve. With only one year published
|
||||||
|
// there is nothing else to fall back on, so this must not read as a pass.
|
||||||
|
mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2));
|
||||||
|
renderPanel(cutoff(800));
|
||||||
|
await check('SE23 3NA');
|
||||||
|
|
||||||
|
const result = await screen.findByRole('status');
|
||||||
|
expect(result).toHaveTextContent(/too close/);
|
||||||
|
expect(result).toHaveTextContent(/measurement error/);
|
||||||
|
// Explanation is supporting text, not part of the bold verdict line.
|
||||||
|
expect(result.querySelector('[class*="cutoffCheckHeadline"]')!.textContent)
|
||||||
|
.not.toMatch(/measurement error/);
|
||||||
|
expect(result).not.toHaveTextContent(/^\S+ away — inside/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a postcode the geocoder cannot find', async () => {
|
||||||
|
mockGeocode.mockResolvedValue(null);
|
||||||
|
renderPanel();
|
||||||
|
await check('ZZ99 9ZZ');
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers from a geocoder failure instead of leaving a stale verdict', async () => {
|
||||||
|
mockGeocode.mockResolvedValue(northOf(200));
|
||||||
|
renderPanel();
|
||||||
|
await check('SE23 3NA');
|
||||||
|
await screen.findByRole('status');
|
||||||
|
|
||||||
|
mockGeocode.mockRejectedValue(new Error('network'));
|
||||||
|
await check('SE23 3NB');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the map behind a request until there is a reason to show it', async () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(screen.queryByTestId('cutoff-map')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
mockGeocode.mockResolvedValue(northOf(200));
|
||||||
|
await check('SE23 3NA');
|
||||||
|
await screen.findByRole('status');
|
||||||
|
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can also show the map without a postcode, on request', () => {
|
||||||
|
renderPanel();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Show this distance on a map/ }));
|
||||||
|
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('states its limits before it is used, not with the answer', () => {
|
||||||
|
renderPanel();
|
||||||
|
const caveat = screen.getByText(/Distance is the last criterion applied/);
|
||||||
|
expect(caveat).toBeInTheDocument();
|
||||||
|
expect(caveat).toHaveTextContent(/not a catchment boundary/);
|
||||||
|
expect(caveat).toHaveTextContent(/walking route/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
* sentence appears only when it is actually true.
|
* sentence appears only when it is actually true.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { screen } from '@testing-library/react';
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||||
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
||||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||||
@@ -30,6 +30,17 @@ describe('primary detail page', () => {
|
|||||||
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2024');
|
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', () => {
|
it('never shows the figure without saying it is not a catchment', () => {
|
||||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() });
|
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() });
|
||||||
|
|
||||||
@@ -88,3 +99,93 @@ describe('secondary detail page', () => {
|
|||||||
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,8 +7,10 @@
|
|||||||
* place — so both are pinned here rather than left to the component.
|
* place — so both are pinned here rather than left to the component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
|
||||||
import { describeCutoff } from '@/components/school/lastDistanceOffered';
|
import {
|
||||||
|
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
|
||||||
|
} from '@/components/school/lastDistanceOffered';
|
||||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||||
|
|
||||||
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
||||||
@@ -30,8 +32,19 @@ describe('formatCutoffDistance', () => {
|
|||||||
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
|
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leads with metres below 100 m, where a miles figure carries nothing', () => {
|
it('stays in miles at short range, where it used to swap to metres', () => {
|
||||||
expect(formatCutoffDistance(27)).toEqual({ primary: '27 m', secondary: '0.02 miles' });
|
// 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', () => {
|
it('returns null rather than a zero cut-off', () => {
|
||||||
@@ -78,3 +91,92 @@ describe('describeCutoff', () => {
|
|||||||
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── "Would we have got in?" ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('compareToCutoff', () => {
|
||||||
|
it('never states the two figures in different units', () => {
|
||||||
|
/*
|
||||||
|
* The reported defect: "69 m away — inside the September 2026 cut-off of
|
||||||
|
* 0.17 miles". Both numbers are correct and the sentence is still useless,
|
||||||
|
* because checking it means converting one of them.
|
||||||
|
*
|
||||||
|
* Swept across the range where the old formatter switched units, so a
|
||||||
|
* future readability tweak to one figure cannot reintroduce the mismatch
|
||||||
|
* in the other.
|
||||||
|
*/
|
||||||
|
const mixed: string[] = [];
|
||||||
|
for (const homeM of [0, 5, 27, 69, 99, 100, 260, 800, 1609, 5000]) {
|
||||||
|
for (const cutoffM of [30, 69, 100, 270, 1000, 3500]) {
|
||||||
|
const { headline } = compareToCutoff(homeM, cutoffM, 2026);
|
||||||
|
const hasMetres = /\d\s?m\b/.test(headline);
|
||||||
|
const milesCount = (headline.match(/miles/g) ?? []).length;
|
||||||
|
// Two figures, both in miles, and no metric reading anywhere near them.
|
||||||
|
if (hasMetres || milesCount !== 2) {
|
||||||
|
mixed.push(`home=${homeM}m cutoff=${cutoffM}m -> ${headline}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(mixed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads back the reported case in one unit', () => {
|
||||||
|
expect(compareToCutoff(69, 270, 2026).headline)
|
||||||
|
.toBe('0.04 miles away — inside the September 2026 cut-off of 0.17 miles.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls a clearly nearer home inside, and names the year', () => {
|
||||||
|
const r = compareToCutoff(300, 800, 2026);
|
||||||
|
expect(r.verdict).toBe('inside');
|
||||||
|
expect(r.headline).toContain('September 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls a clearly further home beyond', () => {
|
||||||
|
expect(compareToCutoff(4000, 800, 2026).verdict).toBe('outside');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to call a result inside the measurement error, either way', () => {
|
||||||
|
// A postcode centroid covers several addresses, so a margin this fine is
|
||||||
|
// noise. With one published year there is no other year to fall back on,
|
||||||
|
// which makes this band the only thing standing between a parent and a
|
||||||
|
// place they do not have.
|
||||||
|
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
||||||
|
expect(compareToCutoff(800 + CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
||||||
|
expect(compareToCutoff(800, 800, 2026).detail).toContain('measurement error');
|
||||||
|
// The explanation is not welded to the headline, so it does not run at
|
||||||
|
// headline weight in the result block.
|
||||||
|
expect(compareToCutoff(800, 800, 2026).headline).not.toContain('measurement error');
|
||||||
|
expect(compareToCutoff(300, 800, 2026).detail).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the band as exclusive at its edge', () => {
|
||||||
|
// Exactly on the boundary is still too close; one metre past it is not.
|
||||||
|
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M, 800, 2026).verdict).toBe('too-close');
|
||||||
|
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M - 1, 800, 2026).verdict).toBe('inside');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeCutoffAbsence', () => {
|
||||||
|
it('explains a selective school by how it admits, not as missing data', () => {
|
||||||
|
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
|
||||||
|
expect(s).toContain('entrance test');
|
||||||
|
expect(s).not.toContain('has not published');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a consistently undersubscribed school as good news', () => {
|
||||||
|
const s = describeCutoffAbsence({
|
||||||
|
localAuthority: 'Camden',
|
||||||
|
admissionsHistory: [
|
||||||
|
{ year: 2022, oversubscribed: false },
|
||||||
|
{ year: 2023, oversubscribed: false },
|
||||||
|
{ year: 2024, oversubscribed: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(s).toContain('has not needed a distance cut-off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('otherwise names the authority that would hold the figure', () => {
|
||||||
|
expect(describeCutoffAbsence({ localAuthority: 'Camden' }))
|
||||||
|
.toContain('Camden has not published');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { SITE_URL, absoluteUrl } from '@/lib/site';
|
||||||
|
|
||||||
|
describe('SITE_URL', () => {
|
||||||
|
it('is the www host, which is the one that serves a 200', () => {
|
||||||
|
// The apex 301s to www at Cloudflare. A canonical pointing at a redirect
|
||||||
|
// is a wasted signal, so every absolute URL we emit must already be www.
|
||||||
|
expect(SITE_URL).toBe('https://www.schoolcompare.co.uk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no trailing slash, so joins never double up', () => {
|
||||||
|
expect(SITE_URL.endsWith('/')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('absoluteUrl', () => {
|
||||||
|
it('joins a rooted path', () => {
|
||||||
|
expect(absoluteUrl('/rankings')).toBe('https://www.schoolcompare.co.uk/rankings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins a path missing its leading slash', () => {
|
||||||
|
expect(absoluteUrl('rankings')).toBe('https://www.schoolcompare.co.uk/rankings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the site root to a bare trailing slash', () => {
|
||||||
|
expect(absoluteUrl('/')).toBe('https://www.schoolcompare.co.uk/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,6 +45,7 @@ export function renderSchoolDetail(fixture: any) {
|
|||||||
>
|
>
|
||||||
<PrimarySchoolSections
|
<PrimarySchoolSections
|
||||||
{...fixture}
|
{...fixture}
|
||||||
|
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||||
nationalAvg={nationalAveragesFixture}
|
nationalAvg={nationalAveragesFixture}
|
||||||
flags={flags}
|
flags={flags}
|
||||||
/>
|
/>
|
||||||
@@ -72,6 +73,8 @@ export function renderSecondarySchoolDetail(fixture: any) {
|
|||||||
>
|
>
|
||||||
<SecondarySchoolSections
|
<SecondarySchoolSections
|
||||||
{...fixture}
|
{...fixture}
|
||||||
|
admissionsHistory={fixture.admissionsHistory ?? []}
|
||||||
|
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||||
nationalAvg={nationalAveragesFixture}
|
nationalAvg={nationalAveragesFixture}
|
||||||
flags={flags}
|
flags={flags}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ export const metadata: Metadata = {
|
|||||||
title: 'School Admissions Guide',
|
title: 'School Admissions Guide',
|
||||||
description:
|
description:
|
||||||
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key 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,6 +5,7 @@
|
|||||||
|
|
||||||
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 {
|
||||||
@@ -14,13 +15,33 @@ interface ComparePageProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
/**
|
||||||
|
* Indexability depends on the query string, so this cannot be a static export.
|
||||||
|
*
|
||||||
|
* Bare /compare is the landing page for the "compare schools" head term and
|
||||||
|
* stays indexable. /compare?urns=… is an unbounded parameter space — 25,193
|
||||||
|
* schools make ~317 million pairs — so it goes noindex. It stays `follow` and
|
||||||
|
* keeps a canonical to the bare path, so the links out to each school page
|
||||||
|
* still count.
|
||||||
|
*/
|
||||||
|
export async function generateMetadata(
|
||||||
|
{ searchParams }: ComparePageProps,
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const { urns } = await searchParams;
|
||||||
|
|
||||||
|
const base: Metadata = {
|
||||||
title: 'Compare Schools',
|
title: 'Compare Schools',
|
||||||
description:
|
description:
|
||||||
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
||||||
keywords:
|
keywords:
|
||||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
'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.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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,
|
||||||
@@ -57,12 +58,12 @@ export const metadata: Metadata = {
|
|||||||
// 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('https://schoolcompare.co.uk'),
|
metadataBase: new URL(SITE_URL),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: 'website',
|
||||||
title: 'schoolcompare | Compare School Performance',
|
title: 'schoolcompare | Compare School Performance',
|
||||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||||
url: 'https://schoolcompare.co.uk',
|
url: SITE_URL,
|
||||||
siteName: 'schoolcompare',
|
siteName: 'schoolcompare',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* 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';
|
||||||
@@ -35,6 +36,10 @@ interface HomePageProps {
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: { absolute: 'schoolcompare | Compare every school in England' },
|
title: { absolute: 'schoolcompare | Compare every school in England' },
|
||||||
description: 'Search and compare school performance across England',
|
description: 'Search and compare school performance across England',
|
||||||
|
// 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,6 +5,7 @@
|
|||||||
|
|
||||||
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 {
|
||||||
@@ -20,6 +21,9 @@ export const metadata: Metadata = {
|
|||||||
title: 'School Rankings',
|
title: 'School Rankings',
|
||||||
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
||||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
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,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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 {
|
||||||
@@ -14,6 +15,6 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
disallow: ['/api/', '/_next/'],
|
disallow: ['/api/', '/_next/'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sitemap: 'https://schoolcompare.co.uk/sitemap.xml',
|
sitemap: absoluteUrl('/sitemap.xml'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,7 +98,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
url: absoluteUrl(canonicalPath),
|
||||||
siteName: 'schoolcompare',
|
siteName: 'schoolcompare',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -106,7 +107,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
|
canonical: absoluteUrl(canonicalPath),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -177,6 +178,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
ofsted: ofsted ?? null,
|
ofsted: ofsted ?? null,
|
||||||
admissions: admissions ?? null,
|
admissions: admissions ?? null,
|
||||||
admissionDistance: admission_distance ?? 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);
|
||||||
@@ -229,6 +231,7 @@ 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}
|
admissionDistance={admission_distance ?? null}
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
|
|||||||
@@ -1,32 +1,8 @@
|
|||||||
/**
|
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() {
|
||||||
let upstream: Response;
|
return proxySitemap('/sitemap.xml');
|
||||||
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' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { proxySitemap } from '@/lib/sitemapProxy';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Children are /sitemaps/static.xml and /sitemaps/schools-{n}.xml. The name is
|
||||||
|
* validated here rather than passed through, so this route cannot be used to
|
||||||
|
* reach arbitrary backend paths.
|
||||||
|
*/
|
||||||
|
const CHILD = /^(static|schools-\d+)\.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}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* LeafletCutoffMapInner
|
||||||
|
* The cut-off distances drawn around the school.
|
||||||
|
*
|
||||||
|
* L.circle takes a radius in metres and projects it properly, which is exactly
|
||||||
|
* what these rings are: a straight-line distance from the school. (L.circleMarker
|
||||||
|
* would take pixels and would silently stop meaning anything as the user zoomed.)
|
||||||
|
*
|
||||||
|
* The latest year is filled and labelled; earlier years are hairlines behind it,
|
||||||
|
* so a tightening cut-off reads as a set of shrinking circles without needing a
|
||||||
|
* legend to decode it. The map auto-fits the widest ring, so the whole history
|
||||||
|
* is in frame whatever the distances are.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
export interface CutoffRing {
|
||||||
|
year: number;
|
||||||
|
radiusM: number;
|
||||||
|
latest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
rings: CutoffRing[];
|
||||||
|
/** The parent's geocoded postcode, once they have entered one. */
|
||||||
|
home: { lat: number; lng: number } | null;
|
||||||
|
interactive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHOOL_PIN = `
|
||||||
|
<div style="position:relative;width:22px;height:22px">
|
||||||
|
<span style="position:absolute;left:50%;top:50%;width:26px;height:26px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
|
||||||
|
<span style="position:absolute;left:50%;top:50%;width:14px;height:14px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand);border:3px solid var(--bg-card)"></span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// A square rotated 45° — a different SHAPE from the school's circle, not just a
|
||||||
|
// different colour, so the two are still distinguishable to anyone who cannot
|
||||||
|
// separate the hues.
|
||||||
|
const HOME_PIN = `
|
||||||
|
<div style="width:18px;height:18px;display:grid;place-items:center">
|
||||||
|
<span style="width:12px;height:12px;background:var(--status-above);border:2.5px solid var(--bg-card);transform:rotate(45deg);border-radius:2px"></span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
function cssVar(name: string, fallback: string): string {
|
||||||
|
if (typeof window === 'undefined') return fallback;
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeafletCutoffMapInner({ lat, lng, rings, home, interactive }: Props) {
|
||||||
|
const elRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const layersRef = useRef<L.Layer[]>([]);
|
||||||
|
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!elRef.current || mapRef.current) return;
|
||||||
|
const map = L.map(elRef.current, {
|
||||||
|
zoomControl: false,
|
||||||
|
attributionControl: true,
|
||||||
|
dragging: false,
|
||||||
|
scrollWheelZoom: false,
|
||||||
|
doubleClickZoom: false,
|
||||||
|
boxZoom: false,
|
||||||
|
keyboard: false,
|
||||||
|
touchZoom: false,
|
||||||
|
}).setView([lat, lng], 14);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
setTimeout(() => map.invalidateSize(), 60);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
layersRef.current = [];
|
||||||
|
zoomCtrlRef.current = null;
|
||||||
|
};
|
||||||
|
}, [lat, lng]);
|
||||||
|
|
||||||
|
// Redraw rings, pins and the viewport whenever the data changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
layersRef.current.forEach((l) => l.remove());
|
||||||
|
layersRef.current = [];
|
||||||
|
|
||||||
|
const brand = cssVar('--brand', '#0F766E');
|
||||||
|
const above = cssVar('--status-above', '#2F855A');
|
||||||
|
|
||||||
|
// Widest first, so the small recent ring is not buried under the old ones.
|
||||||
|
const ordered = [...rings].sort((a, b) => b.radiusM - a.radiusM);
|
||||||
|
for (const r of ordered) {
|
||||||
|
const circle = L.circle([lat, lng], {
|
||||||
|
radius: r.radiusM,
|
||||||
|
color: brand,
|
||||||
|
weight: r.latest ? 3 : 1.5,
|
||||||
|
opacity: r.latest ? 1 : 0.45,
|
||||||
|
fill: r.latest,
|
||||||
|
fillColor: brand,
|
||||||
|
fillOpacity: r.latest ? 0.12 : 0,
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(map);
|
||||||
|
layersRef.current.push(circle);
|
||||||
|
}
|
||||||
|
|
||||||
|
const school = L.marker([lat, lng], {
|
||||||
|
icon: L.divIcon({ className: '', iconSize: [22, 22], iconAnchor: [11, 11], html: SCHOOL_PIN }),
|
||||||
|
keyboard: false,
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(map);
|
||||||
|
layersRef.current.push(school);
|
||||||
|
|
||||||
|
if (home) {
|
||||||
|
const pin = L.marker([home.lat, home.lng], {
|
||||||
|
icon: L.divIcon({ className: '', iconSize: [18, 18], iconAnchor: [9, 9], html: HOME_PIN }),
|
||||||
|
keyboard: false,
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(map);
|
||||||
|
layersRef.current.push(pin);
|
||||||
|
|
||||||
|
const line = L.polyline([[lat, lng], [home.lat, home.lng]], {
|
||||||
|
color: above,
|
||||||
|
weight: 2,
|
||||||
|
dashArray: '5 5',
|
||||||
|
opacity: 0.85,
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(map);
|
||||||
|
layersRef.current.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame the widest ring — and the home pin when it falls outside it, so a
|
||||||
|
// family beyond every cut-off can still see where they sit.
|
||||||
|
const widest = ordered[0]?.radiusM ?? 500;
|
||||||
|
let bounds = L.latLng(lat, lng).toBounds(widest * 2.2);
|
||||||
|
if (home) bounds = bounds.extend(L.latLng(home.lat, home.lng));
|
||||||
|
map.fitBounds(bounds, { padding: [16, 16] });
|
||||||
|
}, [lat, lng, rings, home]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const handlers = [
|
||||||
|
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
|
||||||
|
];
|
||||||
|
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
|
||||||
|
|
||||||
|
if (interactive && !zoomCtrlRef.current) {
|
||||||
|
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
|
||||||
|
zoomCtrlRef.current.addTo(map);
|
||||||
|
} else if (!interactive && zoomCtrlRef.current) {
|
||||||
|
zoomCtrlRef.current.remove();
|
||||||
|
zoomCtrlRef.current = null;
|
||||||
|
}
|
||||||
|
setTimeout(() => map.invalidateSize(), 80);
|
||||||
|
}, [interactive]);
|
||||||
|
|
||||||
|
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
|
||||||
|
}
|
||||||
@@ -9,6 +9,13 @@
|
|||||||
* 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';
|
||||||
@@ -16,7 +23,7 @@ import type { SchoolAdmissions, SchoolAdmissionDistance } 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 } from './AdmissionsViewToggle';
|
import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
|
||||||
import { AdmissionsTrendChart } from './charts';
|
import { AdmissionsTrendChart } from './charts';
|
||||||
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
||||||
|
|
||||||
@@ -63,10 +70,16 @@ export function AdmissionsSection({
|
|||||||
{/* Spans both columns rather than taking a half-width cell. This is the
|
{/* 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
|
figure parents come to the page for, and at tile width the two-line
|
||||||
"0.31 miles / September 2025" pairing wraps badly. */}
|
"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 && (
|
const distanceTile = cutoff && (
|
||||||
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
|
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
|
||||||
<dd className={styles.admissionsTileNum}>
|
<dd className={styles.admissionsTileNum}>
|
||||||
{cutoff.primary}
|
{cutoff.primary}{' '}
|
||||||
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
|
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
|
||||||
</dd>
|
</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>
|
<dt className={styles.admissionsTileLabel}>
|
||||||
@@ -167,18 +180,24 @@ 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">
|
||||||
{showAdmissionsTrend ? (
|
{views.length > 1 ? (
|
||||||
<AdmissionsViewToggle
|
<AdmissionsViewToggle title={title} subtitle={subtitle} views={views} />
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
trendLabel={`${admissionsHistory.length}-year trend`}
|
|
||||||
yearView={yearView}
|
|
||||||
trendView={trendView}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
/* No trend data — render statically, with no client component at all. */
|
/* One view — 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,14 +3,25 @@
|
|||||||
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/.
|
||||||
*
|
*
|
||||||
* Both views are always present in the DOM and visibility is toggled with the
|
* Every view is always present in the DOM and visibility is toggled with the
|
||||||
* `hidden` attribute — matching the previous behaviour exactly — so the
|
* `hidden` attribute, so the server-rendered markup passed in as content never
|
||||||
* server-rendered markup passed in as yearView/trendView never ships as client
|
* ships as client JavaScript — this component carries the state and nothing
|
||||||
* JavaScript.
|
* else.
|
||||||
*
|
*
|
||||||
* 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
|
||||||
@@ -19,39 +30,38 @@ import styles from './schoolSections.module.css';
|
|||||||
export function AdmissionsViewToggle({
|
export function AdmissionsViewToggle({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
trendLabel,
|
views,
|
||||||
yearView,
|
|
||||||
trendView,
|
|
||||||
}: {
|
}: {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
subtitle: ReactNode;
|
subtitle: ReactNode;
|
||||||
trendLabel: string;
|
views: AdmissionsView[];
|
||||||
yearView: ReactNode;
|
|
||||||
trendView: ReactNode;
|
|
||||||
}) {
|
}) {
|
||||||
const [view, setView] = useState<'year' | 'trend'>('year');
|
const [active, setActive] = useState(views[0]?.id);
|
||||||
|
|
||||||
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">
|
||||||
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
|
{views.map((v) => (
|
||||||
This year
|
<button
|
||||||
</button>
|
key={v.id}
|
||||||
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
|
type="button"
|
||||||
{trendLabel}
|
aria-pressed={active === v.id}
|
||||||
|
onClick={() => setActive(v.id)}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
<div className={styles.admissionsViewport}>
|
<div className={styles.admissionsViewport}>
|
||||||
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>
|
{views.map((v) => (
|
||||||
{yearView}
|
<div key={v.id} className={v.className} hidden={active !== v.id}>
|
||||||
</div>
|
{v.content}
|
||||||
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>
|
|
||||||
{trendView}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ 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';
|
||||||
@@ -43,7 +44,8 @@ export interface PrimarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function PrimarySchoolSections({
|
export function PrimarySchoolSections({
|
||||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||||
admissions, admissionsHistory, admissionDistance, deprivation, finance, nationalAvg, flags,
|
admissions, admissionsHistory, admissionDistance,
|
||||||
|
deprivation, finance, nationalAvg, flags,
|
||||||
}: PrimarySchoolSectionsProps) {
|
}: PrimarySchoolSectionsProps) {
|
||||||
const primaryAvg = nationalAvg?.primary ?? {};
|
const primaryAvg = nationalAvg?.primary ?? {};
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
@@ -105,6 +107,10 @@ export function PrimarySchoolSections({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
|||||||
@@ -9,14 +9,18 @@
|
|||||||
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
import type { School, SchoolAdmissions, SchoolAdmissionDistance } 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, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
import {
|
||||||
|
describeCutoff, describeCutoffAbsence,
|
||||||
|
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
|
||||||
|
} from './lastDistanceOffered';
|
||||||
|
|
||||||
export function SecondaryAdmissionsSection({
|
export function SecondaryAdmissionsSection({
|
||||||
admissions, admissionDistance, schoolInfo, hasSixthForm,
|
admissions, admissionsHistory, admissionDistance, schoolInfo, hasSixthForm,
|
||||||
}: {
|
}: {
|
||||||
/* Nullable for the same reason as the primary section: a school can have a
|
/* Nullable for the same reason as the primary section: a school can have a
|
||||||
published cut-off and no EES admissions row. */
|
published cut-off and no EES admissions row. */
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
|
admissionsHistory: SchoolAdmissions[];
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
schoolInfo: School;
|
schoolInfo: School;
|
||||||
hasSixthForm: boolean;
|
hasSixthForm: boolean;
|
||||||
@@ -75,7 +79,7 @@ export function SecondaryAdmissionsSection({
|
|||||||
Last distance offered · {cutoff.entryYear}
|
Last distance offered · {cutoff.entryYear}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>
|
<div className={styles.metricValue}>
|
||||||
{cutoff.primary}
|
{cutoff.primary}{' '}
|
||||||
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
|
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,10 +104,11 @@ export function SecondaryAdmissionsSection({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||||
{schoolInfo.local_authority
|
{describeCutoffAbsence({
|
||||||
? `${schoolInfo.local_authority} has not published a cut-off distance for this school.`
|
localAuthority: schoolInfo.local_authority,
|
||||||
: 'No cut-off distance has been published for this school.'}{' '}
|
admissionsPolicy: schoolInfo.admissions_policy,
|
||||||
Contact the admissions authority for its oversubscription criteria.
|
admissionsHistory,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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';
|
||||||
@@ -32,6 +33,9 @@ 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;
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
@@ -41,7 +45,8 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function SecondarySchoolSections({
|
export function SecondarySchoolSections({
|
||||||
schoolInfo, yearlyData, ofsted, census,
|
schoolInfo, yearlyData, ofsted, census,
|
||||||
admissions, admissionDistance, deprivation, finance, nationalAvg, flags,
|
admissions, admissionsHistory, admissionDistance,
|
||||||
|
deprivation, finance, nationalAvg, flags,
|
||||||
}: SecondarySchoolSectionsProps) {
|
}: SecondarySchoolSectionsProps) {
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
@@ -86,11 +91,15 @@ export function SecondarySchoolSections({
|
|||||||
<SecondaryAdmissionsSection
|
<SecondaryAdmissionsSection
|
||||||
admissions={admissions}
|
admissions={admissions}
|
||||||
admissionDistance={admissionDistance}
|
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}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||||
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
|
||||||
|
|
||||||
export interface CutoffDisplay {
|
export interface CutoffDisplay {
|
||||||
/** Headline figure, e.g. "0.31 miles". */
|
/** Headline figure, e.g. "0.31 miles". */
|
||||||
@@ -59,3 +59,154 @@ export function describeCutoff(
|
|||||||
: null,
|
: 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.';
|
||||||
|
}
|
||||||
@@ -1627,8 +1627,23 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stack both views in one grid cell so the card sizes to the taller view —
|
/*
|
||||||
toggling modes never shifts layout. */
|
* Stack both views in one grid cell so the card sizes to the taller view —
|
||||||
|
* toggling modes never shifts layout.
|
||||||
|
*
|
||||||
|
* This only works while the views are of COMPARABLE height, and it fails
|
||||||
|
* silently rather than loudly when they are not. The hidden view keeps its
|
||||||
|
* box (display stays block below; only visibility is dropped), so the grid
|
||||||
|
* row is sized by the tallest view and every other view is padded out to
|
||||||
|
* match it. A cut-off-distance view carrying a chart, a table and a map was
|
||||||
|
* added here and came to 1402px against the tile grid's 316px, which left
|
||||||
|
* the DEFAULT view — the one nobody had clicked — as four tiles floating in
|
||||||
|
* ~1080px of blank card.
|
||||||
|
*
|
||||||
|
* Anything substantially taller than the tile grid belongs in its own
|
||||||
|
* section, not in this viewport. That is where the distance detail now
|
||||||
|
* lives; see DistanceSection.
|
||||||
|
*/
|
||||||
.admissionsViewport {
|
.admissionsViewport {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@@ -2064,3 +2079,281 @@
|
|||||||
.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);
|
||||||
|
}
|
||||||
@@ -122,6 +122,10 @@ export interface NavItemsInput {
|
|||||||
/** A published cut-off distance renders the Admissions section on its own,
|
/** A published cut-off distance renders the Admissions section on its own,
|
||||||
* so it has to open the nav entry too. */
|
* so it has to open the nav entry too. */
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +140,7 @@ export interface NavItemsInput {
|
|||||||
*/
|
*/
|
||||||
export function buildNavItems(
|
export function buildNavItems(
|
||||||
flags: SchoolFlags,
|
flags: SchoolFlags,
|
||||||
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
|
{ ofsted, admissions, admissionDistance, hasLocation, 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' });
|
||||||
@@ -147,6 +151,9 @@ export function buildNavItems(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions || admissionDistance) 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' });
|
||||||
@@ -216,12 +223,15 @@ export function computeSecondaryFlags({
|
|||||||
*/
|
*/
|
||||||
export function buildSecondaryNavItems(
|
export function buildSecondaryNavItems(
|
||||||
flags: SecondaryFlags,
|
flags: SecondaryFlags,
|
||||||
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
|
{ ofsted, admissions, admissionDistance, hasLocation, 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 || admissionDistance) 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' });
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -354,7 +354,13 @@ 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. */
|
/**
|
||||||
|
* 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;
|
admission_distance: SchoolAdmissionDistance | null;
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
|
|||||||
+31
-17
@@ -505,32 +505,46 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri
|
|||||||
const METRES_PER_MILE = 1609.344;
|
const METRES_PER_MILE = 1609.344;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format an admission cut-off distance.
|
* A distance in miles, the unit UK school admissions is conducted in.
|
||||||
*
|
*
|
||||||
* Miles lead because that is the unit councils publish in — 90% of the source
|
* Councils publish cut-offs in miles — 90% of the collected source rows — and
|
||||||
* rows are miles — and because it is the unit a parent has already been quoted
|
* it is the unit a parent has already been quoted in their council's booklet
|
||||||
* if they have read their council's booklet. The metric figure follows as
|
* and offer letter. Everything a reader is asked to compare therefore renders
|
||||||
* support rather than replacing it.
|
* through this one function, so two figures in the same sentence can never
|
||||||
|
* arrive in different units.
|
||||||
*
|
*
|
||||||
* Below 100 m the miles figure stops carrying information ("0.03 miles"), so
|
* That was not previously true. This formatter used to swap to metres below
|
||||||
* the two swap round and metres lead instead.
|
* 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(
|
export function formatCutoffDistance(
|
||||||
metres: number | null | undefined
|
metres: number | null | undefined
|
||||||
): { primary: string; secondary: string } | null {
|
): { primary: string; secondary: string } | null {
|
||||||
if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
|
if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
|
||||||
|
|
||||||
const miles = metres / METRES_PER_MILE;
|
|
||||||
|
|
||||||
if (metres < 100) {
|
|
||||||
return {
|
return {
|
||||||
primary: `${Math.round(metres)} m`,
|
primary: formatMiles(metres),
|
||||||
secondary: `${miles.toFixed(2)} miles`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
primary: `${miles.toFixed(2)} miles`,
|
|
||||||
secondary: metres < 1000
|
secondary: metres < 1000
|
||||||
? `${Math.round(metres / 10) * 10} m`
|
? `${Math.round(metres / 10) * 10} m`
|
||||||
: `${(metres / 1000).toFixed(1)} km`,
|
: `${(metres / 1000).toFixed(1)} km`,
|
||||||
|
|||||||
@@ -11,6 +11,32 @@ seed-paths: ["seeds"]
|
|||||||
target-path: "target"
|
target-path: "target"
|
||||||
clean-targets: ["target", "dbt_packages"]
|
clean-targets: ["target", "dbt_packages"]
|
||||||
|
|
||||||
|
# schoolcompare publishes England only. GIAS ships the whole UK plus overseas
|
||||||
|
# and offshore establishments, none of which have comparable DfE performance
|
||||||
|
# data — Wales does not publish on the English measures at all. Excluding them
|
||||||
|
# at the mart boundary keeps them out of the site, the filter lists and the
|
||||||
|
# sitemap together. Codes are TypeOfEstablishment, see seeds/gias_code_names.csv.
|
||||||
|
# 25 = Offshore schools (Jersey, Guernsey, Isle of Man, Gibraltar)
|
||||||
|
# 26 = Service children's education (BFPO, Fieldwork Overseas)
|
||||||
|
# 30 = Welsh establishment
|
||||||
|
# 37 = British schools overseas
|
||||||
|
# dim_school, dim_location and assert_england_only_schools all read this list.
|
||||||
|
vars:
|
||||||
|
non_england_school_type_codes: [25, 26, 30, 37]
|
||||||
|
|
||||||
|
# Type alone is not enough. Beechwood College (URN 142458, Sully, CF64 5SE)
|
||||||
|
# is a Welsh school that GIAS types as a Special post 16 institution (32)
|
||||||
|
# rather than a Welsh establishment (30), so the type filter let it through.
|
||||||
|
# LA code is the reliable discriminator: GIAS gives the 22 Welsh unitary
|
||||||
|
# authorities the contiguous block 660-681, which English authorities never
|
||||||
|
# use (they occupy 201-384 and 800-938).
|
||||||
|
#
|
||||||
|
# Filtering on postcode would be wrong: Redbrook, Tutshill, Wyedean and two
|
||||||
|
# other Gloucestershire schools carry NP16/NP25 postcodes because Royal Mail
|
||||||
|
# areas straddle the border, and they are English schools with English data.
|
||||||
|
welsh_la_code_min: 660
|
||||||
|
welsh_la_code_max: 681
|
||||||
|
|
||||||
models:
|
models:
|
||||||
school_compare:
|
school_compare:
|
||||||
staging:
|
staging:
|
||||||
|
|||||||
@@ -31,5 +31,11 @@ select
|
|||||||
else null
|
else null
|
||||||
end as longitude
|
end as longitude
|
||||||
from {{ ref('stg_gias_establishments') }} s
|
from {{ ref('stg_gias_establishments') }} s
|
||||||
-- Must match dim_school's status filter exactly (the API inner-joins the two).
|
-- Must match dim_school's status and England filters exactly (the API
|
||||||
|
-- inner-joins the two).
|
||||||
where s.status_code in (1, 3)
|
where s.status_code in (1, 3)
|
||||||
|
-- coalesce, not a bare NOT IN: a null type code would make the predicate
|
||||||
|
-- null and drop the row silently. Unknown type is not grounds for exclusion.
|
||||||
|
and coalesce(s.school_type_code, -1) not in ({{ var('non_england_school_type_codes') | join(', ') }})
|
||||||
|
-- Welsh schools GIAS does not type as Welsh establishments (see vars).
|
||||||
|
and coalesce(s.local_authority_code, -1) not between {{ var('welsh_la_code_min') }} and {{ var('welsh_la_code_max') }}
|
||||||
@@ -91,3 +91,10 @@ left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn
|
|||||||
-- 1 = Open; 3 = Open, but proposed to close (still operating; drops out when
|
-- 1 = Open; 3 = Open, but proposed to close (still operating; drops out when
|
||||||
-- GIAS flips to Closed — marts fully rebuild each run).
|
-- GIAS flips to Closed — marts fully rebuild each run).
|
||||||
where s.status_code in (1, 3)
|
where s.status_code in (1, 3)
|
||||||
|
-- England only. dim_location must apply this filter identically (the API
|
||||||
|
-- inner-joins the two). See vars in dbt_project.yml for what the codes are.
|
||||||
|
-- coalesce, not a bare NOT IN: a null type code would make the predicate
|
||||||
|
-- null and drop the row silently. Unknown type is not grounds for exclusion.
|
||||||
|
and coalesce(s.school_type_code, -1) not in ({{ var('non_england_school_type_codes') | join(', ') }})
|
||||||
|
-- Welsh schools GIAS does not type as Welsh establishments (see vars).
|
||||||
|
and coalesce(s.local_authority_code, -1) not between {{ var('welsh_la_code_min') }} and {{ var('welsh_la_code_max') }}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Custom test: the published corpus is England only.
|
||||||
|
--
|
||||||
|
-- GIAS ships the whole UK plus overseas and offshore establishments. None of
|
||||||
|
-- them carry comparable DfE performance data, so dim_school and dim_location
|
||||||
|
-- filter them out (see vars.non_england_school_type_codes in dbt_project.yml).
|
||||||
|
-- This test is the guard: a GIAS refresh that reintroduces them, or an edit
|
||||||
|
-- that drops the filter from one of the two models, fails the pipeline rather
|
||||||
|
-- than quietly republishing ~2,000 dataless pages to the site and the sitemap.
|
||||||
|
|
||||||
|
select
|
||||||
|
urn,
|
||||||
|
school_name,
|
||||||
|
school_type_code
|
||||||
|
from {{ ref('dim_school') }}
|
||||||
|
where school_type_code in ({{ var('non_england_school_type_codes') | join(', ') }})
|
||||||
|
|
||||||
|
union all
|
||||||
|
|
||||||
|
-- dim_location is inner-joined to dim_school by the API, so it must filter
|
||||||
|
-- identically. Anything here that dim_school does not have means the two
|
||||||
|
-- models have drifted apart.
|
||||||
|
select
|
||||||
|
l.urn,
|
||||||
|
null::text as school_name,
|
||||||
|
null::integer as school_type_code
|
||||||
|
from {{ ref('dim_location') }} l
|
||||||
|
left join {{ ref('dim_school') }} s on l.urn = s.urn
|
||||||
|
where s.urn is null
|
||||||
|
|
||||||
|
union all
|
||||||
|
|
||||||
|
-- Belt and braces on the Welsh LA code range.
|
||||||
|
--
|
||||||
|
-- The range 660-681 is documented GIAS structure, not something this project
|
||||||
|
-- can verify from its own data — local_authority_code is not exposed anywhere
|
||||||
|
-- the app can assert on. So the range does the filtering and the authority
|
||||||
|
-- NAME does the checking: if 660-681 is ever wrong or incomplete, a Welsh
|
||||||
|
-- authority reappears here and the pipeline fails loudly rather than the
|
||||||
|
-- corpus quietly regrowing a Welsh tail.
|
||||||
|
--
|
||||||
|
-- Beechwood College (142458) is why this exists: a Welsh school GIAS types as
|
||||||
|
-- a Special post 16 institution, which the type filter alone let through.
|
||||||
|
select
|
||||||
|
l.urn,
|
||||||
|
l.local_authority_name as school_name,
|
||||||
|
null::integer as school_type_code
|
||||||
|
from {{ ref('dim_location') }} l
|
||||||
|
where l.local_authority_name in (
|
||||||
|
'Isle of Anglesey', 'Gwynedd', 'Conwy', 'Denbighshire', 'Flintshire',
|
||||||
|
'Wrexham', 'Powys', 'Ceredigion', 'Pembrokeshire', 'Carmarthenshire',
|
||||||
|
'Swansea', 'Neath Port Talbot', 'Bridgend', 'Vale of Glamorgan',
|
||||||
|
'Rhondda Cynon Taf', 'Merthyr Tydfil', 'Caerphilly', 'Blaenau Gwent',
|
||||||
|
'Torfaen', 'Monmouthshire', 'Newport', 'Cardiff'
|
||||||
|
)
|
||||||
Reference in new issue
Block a user